diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index c0f0021db1..c5be64a70f 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -669,6 +669,12 @@ Gets the for the bound value. + + + Gets the display name of the field, using the specified display name if set; otherwise, uses the field + identifier's name if the field is bound. + + Gets or sets the current value of the input. @@ -767,6 +773,16 @@ + + + Defines an interface for components with values that are sensitive to culture settings, eg : parsing to string. + + + + + Gets or sets the culture of the component. + + Because of the limitation of the web component, the maximum value is set to 9999999999 for really large numbers. @@ -779,6 +795,11 @@ The minimum value for the underlying type + + + Gets or sets the error message to show when the field can not be parsed. + + Gets or sets the content to be rendered inside the component. @@ -2999,6 +3020,9 @@ By default to display using the OS culture. + + + Function to know if a specific day must be disabled. @@ -6703,6 +6727,9 @@ ⚠️ Only available when Multiple = true. + + + @@ -8127,11 +8154,6 @@ An Id value must be set to use this property. - - - Gets or sets the error message to show when the field can not be parsed. - - Gets or sets the content to be rendered inside the component. @@ -8143,6 +8165,9 @@ unless an explicit value for Min or Max is provided. + + + Formats the value as a string. Derived classes can override this to determine the formatting used for CurrentValueAsString. @@ -9036,6 +9061,9 @@ Gets or sets the child content to be rendering inside the . + + + @@ -9091,6 +9119,9 @@ Fires when hovered value changes. Value will be null if no rating item is hovered. + + + diff --git a/src/Core/Components/Base/FluentInputBase.cs b/src/Core/Components/Base/FluentInputBase.cs index fafe6c1762..5cef9e3f3c 100644 --- a/src/Core/Components/Base/FluentInputBase.cs +++ b/src/Core/Components/Base/FluentInputBase.cs @@ -145,6 +145,12 @@ public abstract partial class FluentInputBase : FluentComponentBase, IDi internal virtual bool FieldBound => Field is not null || ValueExpression is not null || ValueChanged.HasDelegate; + /// + /// Gets the display name of the field, using the specified display name if set; otherwise, uses the field + /// identifier's name if the field is bound. + /// + internal string FieldDisplayName => DisplayName ?? (FieldBound ? FieldIdentifier.FieldName : UnknownBoundField); + protected async Task SetCurrentValueAsync(TValue? value) { var hasChanged = !EqualityComparer.Default.Equals(value, Value); diff --git a/src/Core/Components/Base/ICultureSensitiveComponent.cs b/src/Core/Components/Base/ICultureSensitiveComponent.cs new file mode 100644 index 0000000000..b49ed2d482 --- /dev/null +++ b/src/Core/Components/Base/ICultureSensitiveComponent.cs @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Globalization; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Defines an interface for components with values that are sensitive to culture settings, eg : parsing to string. +/// +public interface ICultureSensitiveComponent +{ + /// + /// Gets or sets the culture of the component. + /// + public CultureInfo Culture { get; set; } +} diff --git a/src/Core/Components/Base/IStringParsableComponent.cs b/src/Core/Components/Base/IStringParsableComponent.cs new file mode 100644 index 0000000000..3b9cc72ec3 --- /dev/null +++ b/src/Core/Components/Base/IStringParsableComponent.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Defines an interface for components with values that can be parsed from a string. +/// +public interface IStringParsableComponent +{ + /// + /// Gets or sets the error message to show when the field can not be parsed. + /// + public string ParsingErrorMessage { get; set; } +} diff --git a/src/Core/Components/DateTime/FluentCalendar.razor.cs b/src/Core/Components/DateTime/FluentCalendar.razor.cs index 97d578f29c..9cdd3edaeb 100644 --- a/src/Core/Components/DateTime/FluentCalendar.razor.cs +++ b/src/Core/Components/DateTime/FluentCalendar.razor.cs @@ -293,9 +293,9 @@ private async Task PickerYearSelectAsync(DateTime? year) /// protected override bool TryParseValueFromString(string? value, out DateTime? result, [NotNullWhen(false)] out string? validationErrorMessage) { - BindConverter.TryConvertTo(value, Culture, out result); - validationErrorMessage = null; - return true; + bool success = BindConverter.TryConvertTo(value, Culture, out result); + validationErrorMessage = success ? null : string.Format(ParsingErrorMessage, FieldDisplayName); + return success; } /// diff --git a/src/Core/Components/DateTime/FluentCalendarBase.cs b/src/Core/Components/DateTime/FluentCalendarBase.cs index df0ae1ff8b..0b8a60dfb8 100644 --- a/src/Core/Components/DateTime/FluentCalendarBase.cs +++ b/src/Core/Components/DateTime/FluentCalendarBase.cs @@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public abstract class FluentCalendarBase : FluentInputBase +public abstract class FluentCalendarBase : FluentInputBase , ICultureSensitiveComponent, IStringParsableComponent { /// /// Gets or sets the culture of the component. @@ -16,6 +16,10 @@ public abstract class FluentCalendarBase : FluentInputBase [Parameter] public virtual CultureInfo Culture { get; set; } = CultureInfo.CurrentCulture; + /// + [Parameter] + public virtual string ParsingErrorMessage { get; set; } = "The {0} field must have a valid format."; + /// /// Function to know if a specific day must be disabled. /// diff --git a/src/Core/Components/DateTime/FluentDatePicker.razor.cs b/src/Core/Components/DateTime/FluentDatePicker.razor.cs index ee6452c12d..35e018c657 100644 --- a/src/Core/Components/DateTime/FluentDatePicker.razor.cs +++ b/src/Core/Components/DateTime/FluentDatePicker.razor.cs @@ -145,10 +145,9 @@ protected override bool TryParseValueFromString(string? value, out DateTime? res value = new DateTime(year, 1, 1).ToString(Culture.DateTimeFormat.ShortDatePattern); } - BindConverter.TryConvertTo(value, Culture, out result); - - validationErrorMessage = null; - return true; + bool success = BindConverter.TryConvertTo(value, Culture, out result); + validationErrorMessage = success ? null : string.Format(ParsingErrorMessage, FieldDisplayName); + return success; } private string PlaceholderAccordingToView() diff --git a/src/Core/Components/List/ListComponentBase.razor.cs b/src/Core/Components/List/ListComponentBase.razor.cs index a21bef098b..7ae9a4218a 100644 --- a/src/Core/Components/List/ListComponentBase.razor.cs +++ b/src/Core/Components/List/ListComponentBase.razor.cs @@ -17,7 +17,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// Component that provides a list of options. /// /// -public abstract partial class ListComponentBase : FluentInputBase, IAsyncDisposable where TOption : notnull +public abstract partial class ListComponentBase : FluentInputBase, IAsyncDisposable, IStringParsableComponent where TOption : notnull { private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/List/ListComponentBase.razor.js"; @@ -212,6 +212,10 @@ protected string? InternalValue [Parameter] public Expression>>? SelectedOptionsExpression { get; set; } + /// + [Parameter] + public string ParsingErrorMessage { get; set; } = "The {0} field must be a (valid) number."; + /// protected ListComponentBase() { diff --git a/src/Core/Components/NumberField/FluentNumberField.razor.cs b/src/Core/Components/NumberField/FluentNumberField.razor.cs index 3c2014fa49..9c725eb167 100644 --- a/src/Core/Components/NumberField/FluentNumberField.razor.cs +++ b/src/Core/Components/NumberField/FluentNumberField.razor.cs @@ -10,7 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public partial class FluentNumberField : FluentInputBase, IAsyncDisposable +public partial class FluentNumberField : FluentInputBase, IAsyncDisposable, IStringParsableComponent { private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/TextField/FluentTextField.razor.js"; @@ -86,12 +86,6 @@ public partial class FluentNumberField : FluentInputBase, IAsync [Parameter] public string? AutoComplete { get; set; } - /// - /// Gets or sets the error message to show when the field can not be parsed. - /// - [Parameter] - public string ParsingErrorMessage { get; set; } = "The {0} field must be a (valid) number."; - /// /// Gets or sets the content to be rendered inside the component. /// @@ -105,6 +99,10 @@ public partial class FluentNumberField : FluentInputBase, IAsync [Parameter] public bool UseTypeConstraints { get; set; } + /// + [Parameter] + public string ParsingErrorMessage { get; set; } = "The {0} field must be a (valid) number."; + private static readonly string _stepAttributeValue = GetStepAttributeValue(); // If type constraints is true and min is null, set min to the minimum value of TValue. diff --git a/src/Core/Components/Radio/FluentRadioGroup.razor.cs b/src/Core/Components/Radio/FluentRadioGroup.razor.cs index 3fdaa59322..d4a5243b0a 100644 --- a/src/Core/Components/Radio/FluentRadioGroup.razor.cs +++ b/src/Core/Components/Radio/FluentRadioGroup.razor.cs @@ -15,7 +15,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// [CascadingTypeParameter(nameof(TValue))] -public partial class FluentRadioGroup<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue> : FluentInputBase +public partial class FluentRadioGroup<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue> : FluentInputBase, IStringParsableComponent { private readonly string _defaultGroupName = Identifier.NewId(); private FluentRadioContext? _context; @@ -40,6 +40,10 @@ public FluentRadioGroup() [CascadingParameter] private FluentRadioContext? CascadedContext { get; set; } + /// + [Parameter] + public string ParsingErrorMessage { get; set; } = "The {0} field must be a (valid) number."; + /// protected override void OnParametersSet() { diff --git a/src/Core/Components/Rating/FluentRating.razor.cs b/src/Core/Components/Rating/FluentRating.razor.cs index 538a28e28b..86ef821410 100644 --- a/src/Core/Components/Rating/FluentRating.razor.cs +++ b/src/Core/Components/Rating/FluentRating.razor.cs @@ -10,7 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -public partial class FluentRating : FluentInputBase +public partial class FluentRating : FluentInputBase, IStringParsableComponent { private bool _updatingCurrentValue = false; private int? _hoverValue = null; @@ -74,6 +74,10 @@ public partial class FluentRating : FluentInputBase [Parameter] public EventCallback OnHoverValueChanged { get; set; } + /// + [Parameter] + public string ParsingErrorMessage { get; set; } = "The {0} field must be a number."; + /// private string GroupName => Id ?? $"rating-{Id}"; @@ -90,8 +94,7 @@ protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(fa } else { - validationErrorMessage = string.Format(CultureInfo.InvariantCulture, - "The {0} field must be a number.", + validationErrorMessage = string.Format(ParsingErrorMessage, FieldBound ? FieldIdentifier.FieldName : UnknownBoundField); return false; } diff --git a/src/Core/Extensions/FluentInputExtensions.cs b/src/Core/Extensions/FluentInputExtensions.cs index 893e7353dc..e011d1b72e 100644 --- a/src/Core/Extensions/FluentInputExtensions.cs +++ b/src/Core/Extensions/FluentInputExtensions.cs @@ -10,13 +10,15 @@ namespace Microsoft.FluentUI.AspNetCore.Components.Extensions; internal static class FluentInputExtensions { - public static bool TryParseSelectableValueFromString<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TValue>( - this FluentInputBase input, string? value, + public static bool TryParseSelectableValueFromString( + this TInput input, string? value, [MaybeNullWhen(false)] out TValue result, - [NotNullWhen(false)] out string? validationErrorMessage) + [NotNullWhen(false)] out string? validationErrorMessage) where TInput : FluentInputBase, IStringParsableComponent { try { + var culture = (input as ICultureSensitiveComponent)?.Culture ?? CultureInfo.CurrentCulture; + // We special-case bool values because BindConverter reserves bool conversion for conditional attributes. if (typeof(TValue) == typeof(bool)) { @@ -34,7 +36,7 @@ internal static class FluentInputExtensions return true; } } - else if (BindConverter.TryConvertTo(value, CultureInfo.CurrentCulture, out var parsedValue)) + else if (BindConverter.TryConvertTo(value, culture, out var parsedValue)) { result = parsedValue; validationErrorMessage = null; @@ -42,7 +44,7 @@ internal static class FluentInputExtensions } result = default; - validationErrorMessage = $"The {input.DisplayName ?? (input.FieldBound ? input.FieldIdentifier.FieldName : input.UnknownBoundField)} field is not valid."; + validationErrorMessage = string.Format(input.ParsingErrorMessage, input.FieldDisplayName); return false; } catch (InvalidOperationException ex) diff --git a/tests/Core/DateTime/FluentDatePickerTests.cs b/tests/Core/DateTime/FluentDatePickerTests.cs index b0b06383eb..a680496858 100644 --- a/tests/Core/DateTime/FluentDatePickerTests.cs +++ b/tests/Core/DateTime/FluentDatePickerTests.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; using System.Globalization; using Bunit; using Microsoft.AspNetCore.Components; @@ -292,4 +293,42 @@ public void FluentDatePicker_OnDoubleClickEventTriggers() // Assert Assert.Equal(expected, actual); } + + [Theory] + [InlineData("02-22-2000", "en-GB", "02/22/2000")] + [InlineData("02-22-2000", "nl-BE", null)] + [InlineData("22-12-2000", "nl-BE", "22/12/2000")] + [InlineData("02/22/2000", "en-GB", "02/22/2000")] + [InlineData("abc", "en-GB", null)] + [InlineData("02022000", "en-GB", null)] + public void FluentDatePicker_TryParseValueFromString(string? value, string? cultureName, string? expectedValue) + { + // Arrange + var picker = new TestDatePicker(); + var culture = cultureName != null ? CultureInfo.GetCultureInfo(cultureName) : CultureInfo.InvariantCulture; + + // Act + picker.Culture = culture; + var successfullParse = picker.CallTryParseValueFromString(value, out var resultDate, out var validationErrorMessage); + + // Assert + if (successfullParse) + { + Assert.Equal(expectedValue, resultDate?.ToString(culture.DateTimeFormat.ShortDatePattern, culture)); + } + else + { + Assert.Null(resultDate); + Assert.NotNull(validationErrorMessage); + } + } + + // Temporary class to expose protected method + private class TestDatePicker : FluentDatePicker + { + public bool CallTryParseValueFromString(string? value, out System.DateTime? result, [NotNullWhen(false)] out string? validationErrorMessage) + { + return base.TryParseValueFromString(value, out result, out validationErrorMessage); + } + } }