Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
<FluentDatePicker Name="production_date" Id="proddate" @bind-Value="starship.ProductionDate" Label="Production Date" Required="true" />
</div>
<div>
<FluentSwitch Name="teleporter" @bind-Value="starship.HasTeleporter" Label="Has a Teleporter" />
<FluentSwitch Name="teleporter" @bind-Value="starship.HasTeleporter" Label="Has a Teleporter" Required="true" />
</div>
<FluentButton Type="ButtonType.Submit" Appearance="ButtonAppearance.Primary">Submit</FluentButton>
</FluentStack>
Expand Down
5 changes: 3 additions & 2 deletions examples/Tools/FluentUI.Demo.SampleData/Starship.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class Starship
/// <summary>
/// The unique identifier for the starship.
/// </summary>
[Required]
[Required(ErrorMessage = "Identifier is required")]
[MinLength(3, ErrorMessage = "Identifier is too short")]
[StringLength(16, ErrorMessage = "Identifier too long (16 character limit)")]
public string? Identifier { get; set; }
Expand Down Expand Up @@ -52,7 +52,7 @@ public class Starship
/// <summary>
/// Indicates whether the starship design has been validated.
/// </summary>
[Required]
[Required(ErrorMessage = "Validation is required")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — The new boolean Required annotations do not validate false

The added/updated [Required] attributes on non-nullable bool properties in Starship.cs always pass because a non-nullable Boolean is never null. The adjacent [Range(typeof(bool), "true", "true")] attributes perform the actual “must be true” validation, so the new RequiredAttribute.ErrorMessage cannot demonstrate the behavior this PR intends to showcase.

Suggested fix: remove [Required] from these Boolean properties and keep the enforcement/message on [Range], or use a nullable Boolean if null is the state being validated.

[Range(typeof(bool), "true", "true",
ErrorMessage = "This form disallows unapproved ships")]
public bool IsValidatedDesign { get; set; }
Expand All @@ -66,6 +66,7 @@ public class Starship
/// <summary>
/// Gets or sets a value indicating whether the starship is equipped with a teleporter.
/// </summary>
[Required]
[Range(typeof(bool), "true", "true", ErrorMessage = "Having a teleporter is required")]
public bool HasTeleporter { get; set; }
}
129 changes: 129 additions & 0 deletions src/Core/Components/Base/FluentInputBase.Validation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// ------------------------------------------------------------------------
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------

using System.ComponentModel.DataAnnotations;
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.AspNetCore.Components.Forms;

namespace Microsoft.FluentUI.AspNetCore.Components;

public abstract partial class FluentInputBase<TValue>
{
/// <summary>
/// Determines whether a required-field message condition is met.
/// </summary>
/// <param name="field">The field whose focus state may be used.</param>
/// <param name="isEmpty">Determines whether the current value is empty.</param>
/// <param name="useFieldFocusLost">Whether to use the field's focus state instead of this component's.</param>
/// <param name="fieldIdentifier">
/// The field identifier to use for validation messages, if different from this component's.
/// </param>
protected bool IsRequiredMessageConditionMet(IFluentField field, Func<bool> isEmpty, bool useFieldFocusLost = false, FieldIdentifier? fieldIdentifier = null)
{
return EditContext?.GetValidationMessages(fieldIdentifier ?? FieldIdentifier).Any() != true &&
(useFieldFocusLost ? field.FocusLost : FocusLost) &&
(Required ?? false) &&
!(Disabled ?? false) &&
!ReadOnly &&
isEmpty();
}

/// <summary>
/// Sets the default required-field message on the supplied field.
/// </summary>
/// <param name="field">The field receiving the message.</param>
/// <param name="fieldIdentifier">
/// The field identifier whose RequiredAttribute should supply the message, if different from this component's.
/// </param>
protected void SetRequiredErrorMessage(IFluentField field, FieldIdentifier? fieldIdentifier = null)
{
field.MessageIcon = FluentStatus.ErrorIcon;
field.Message = GetRequiredErrorMessage(fieldIdentifier ?? FieldIdentifier);
}

/// <summary>
/// Creates the default required-field message condition for an input component.
/// </summary>
/// <param name="isEmpty">Determines whether the current value is empty.</param>
/// <param name="useFieldFocusLost">Whether to use the field's focus state instead of this component's.</param>
/// <param name="fieldIdentifierProvider">
/// Provides the field identifier when it differs from this component's value expression.
/// </param>
protected Func<IFluentField, bool> CreateRequiredMessageCondition(Func<bool> isEmpty, bool useFieldFocusLost = false, Func<FieldIdentifier>? fieldIdentifierProvider = null)
{
return field =>
{
var fieldIdentifier = fieldIdentifierProvider?.Invoke() ?? FieldIdentifier;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Required metadata and validation-message lookup can target different fields

ValidationFieldExpression prefers ValidationFieldFor, but CreateRequiredMessageCondition defaults to the component's FieldIdentifier in FluentInputBase.Validation.cs. For wrappers where the text binding differs from the model field, required-attribute lookup and validation-message suppression can therefore inspect different properties, causing a generic message or duplicate feedback.

Suggested fix: derive one FieldIdentifier from ValidationFieldExpression and use it consistently for both metadata lookup and EditContext.GetValidationMessages.

if (EditContext?.GetValidationMessages(fieldIdentifier).Any() == true)
{
return false;
}

SetRequiredErrorMessage(field, fieldIdentifier);
return IsRequiredMessageConditionMet(field, isEmpty, useFieldFocusLost, fieldIdentifier);
};
}

private string GetRequiredErrorMessage(FieldIdentifier fieldIdentifier)
{
var property = FindValidationProperty(fieldIdentifier);

var requiredAttribute =
property?.GetCustomAttribute<RequiredAttribute>();

if (requiredAttribute is null ||
(requiredAttribute.ErrorMessage is null &&
requiredAttribute.ErrorMessageResourceName is null))
{
return Localizer[Localization.LanguageResource.FluentInputBase_RequiredMessage];
}

var displayName =
property?.GetCustomAttribute<DisplayAttribute>()?.GetName() ??
fieldIdentifier.FieldName;

return requiredAttribute.FormatErrorMessage(displayName);
}

private PropertyInfo? FindValidationProperty(FieldIdentifier fieldIdentifier)
{
var property = GetProperty(ValidationFieldExpression);

return property is not null &&
string.Equals(property.Name, fieldIdentifier.FieldName, StringComparison.Ordinal)
? property
: null;
}

private static PropertyInfo? GetProperty(LambdaExpression? expression)
{
if (expression is null)
{
return null;
}

var body = expression.Body;

while (body is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked, } conversion)
{
body = conversion.Operand;
}

return body is MemberExpression { Member: PropertyInfo property, } ? property : null;
}

/// <summary>
/// Gets the expression identifying the model field used for validation.
/// </summary>
protected virtual LambdaExpression? ValidationFieldExpression => ValidationFieldFor ?? ValueExpression;

/// <summary>
/// Gets a value indicating whether the value expression was supplied for a bound model field.
/// </summary>
protected bool HasExplicitValueExpression
=> GetProperty(ValueExpression) is { } property &&
(property.DeclaringType != typeof(FluentInputBase<TValue>) ||
!string.Equals(property.Name, nameof(CurrentValueOrDefault), StringComparison.Ordinal));
}
2 changes: 2 additions & 0 deletions src/Core/Components/Checkbox/FluentCheckbox.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public partial class FluentCheckbox : FluentInputBase<bool>, IFluentComponentEle
public FluentCheckbox(LibraryConfiguration configuration) : base(configuration)
{
LabelPosition = Components.LabelPosition.After;

MessageCondition = CreateRequiredMessageCondition(() => !Value);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — Default required conditions hide caller-supplied messages on Checkbox, Switch, and RadioGroup

The new constructor defaults in FluentCheckbox.razor.cs, FluentSwitch, and FluentRadioGroup replace the previous implicit Always condition. The shared condition also calls SetRequiredErrorMessage before checking whether the component is required, focused out, or empty in FluentInputBase.Validation.cs.

Consequently, a consumer-provided Message without a custom MessageCondition is no longer displayed and can be overwritten even when Required is false.

Suggested fix: preserve explicit message behavior. Compute the fallback required message only after the required predicate succeeds, and do not mutate a caller-supplied Message/MessageIcon.

}

/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public FluentColorPickerInput(LibraryConfiguration configuration) : base(configu
if (FocusLost && (Required ?? false) && !(Disabled ?? false) && !ReadOnly && string.IsNullOrEmpty(CurrentValueAsString))
{
field.MessageIcon = FluentStatus.ErrorIcon;
field.Message = Localizer[Localization.LanguageResource.TextInput_RequiredMessage];
field.Message = Localizer[Localization.LanguageResource.FluentInputBase_RequiredMessage];
return true;
}

Expand Down
18 changes: 1 addition & 17 deletions src/Core/Components/DateTime/FluentCalendar.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,7 @@ public partial class FluentCalendar<TValue> : FluentCalendarBase<TValue>
/// <param name="configuration">The configuration settings used to initialize the calendar. Cannot be null.</param>
public FluentCalendar(LibraryConfiguration configuration) : base(configuration)
{
// Default conditions for the message
MessageCondition = (field) =>
{
if (EditContext?.GetValidationMessages(FieldIdentifier).Any() == true)
{
return false;
}

field.MessageIcon = FluentStatus.ErrorIcon;
field.Message = Localizer[Localization.LanguageResource.Calendar_RequiredMessage];

return FocusLost &&
(Required ?? false)
&& !(Disabled ?? false)
&& !ReadOnly
&& CurrentValue.IsNullOrDefault();
};
MessageCondition = CreateRequiredMessageCondition(() => CurrentValue.IsNullOrDefault());
}

/// <summary />
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Components/DateTime/FluentDatePicker.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public FluentDatePicker(LibraryConfiguration configuration) : base(configuration
}

field.MessageIcon = FluentStatus.ErrorIcon;
field.Message = Localizer[Localization.LanguageResource.TextInput_RequiredMessage];
field.Message = Localizer[Localization.LanguageResource.FluentInputBase_RequiredMessage];

return FocusLost &&
(Required ?? false)
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Components/DateTime/FluentTimePicker.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public FluentTimePicker(LibraryConfiguration configuration) : base(configuration
}

field.MessageIcon = FluentStatus.ErrorIcon;
field.Message = Localizer[Localization.LanguageResource.TextInput_RequiredMessage];
field.Message = Localizer[Localization.LanguageResource.FluentInputBase_RequiredMessage];

return FocusLost &&
(Required ?? false)
Expand Down
30 changes: 15 additions & 15 deletions src/Core/Components/Field/FluentField.razor
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,23 @@ else
<FluentValidationMessage TValue="object"
Field="@_fieldIdentifier" />
}
else {
if (!HasValidationMessages && HasMessageOrCondition && Parameters.MessageCondition?.Invoke(InputComponent ?? this) == true)
Comment thread
vnbaaij marked this conversation as resolved.
{
<FluentText slot="@FluentSlot.FieldMessage" Size="@TextSize.Size200" Style="@(Parameters.MessageIcon == FluentStatus.ErrorIcon ? "color: var(--error);" : "")">

@if (HasMessageOrCondition && Parameters.MessageCondition?.Invoke(InputComponent ?? this) == true)
{
<FluentText slot="@FluentSlot.FieldMessage"
size="@TextSize.Size200">

@CreateIcon(Parameters.MessageIcon)
@CreateIcon(Parameters.MessageIcon)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — FluentField now suppresses all custom messages when validation errors exist

The new else in FluentField.razor prevents every MessageCondition message from rendering whenever the EditContext has a validation message. This also suppresses unrelated hints, warnings, and status messages. The generated required predicate already checks for validation messages, so the broad rendering-level suppression is unnecessary for avoiding duplicate required messages.

Suggested fix: keep custom message rendering independent. Suppress only the generated required fallback when an EditContext validation message exists.


@if (Parameters.MessageTemplate is not null)
{
@Parameters.MessageTemplate
}
else
{
@Parameters.Message
}
</FluentText>
@if (Parameters.MessageTemplate is not null)
{
@Parameters.MessageTemplate
}
else
{
@Parameters.Message
}
</FluentText>
}
}

</fluent-field>
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Components/Field/FluentField.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ private bool HasMessageOrCondition
? CurrentEditContext.GetValidationMessages(_fieldIdentifier)
: [];

private static FieldIdentifier CreateFieldIdentifier(LambdaExpression accessor)
internal static FieldIdentifier CreateFieldIdentifier(LambdaExpression accessor)
{
var accessorBody = accessor.Body;
while (accessorBody is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unaryExpression)
Expand Down
18 changes: 1 addition & 17 deletions src/Core/Components/NumberInput/FluentNumberInput.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,23 +48,7 @@ public FluentNumberInput(LibraryConfiguration configuration) : base(configuratio
Max = (TValue)defaults.Max;
Step = (TValue)defaults.Step;

// Default conditions for the message
MessageCondition = (field) =>
{
if (EditContext?.GetValidationMessages(FieldIdentifier).Any() == true)
{
return false;
}

field.MessageIcon = FluentStatus.ErrorIcon;
field.Message = Localizer[Localization.LanguageResource.TextInput_RequiredMessage];

return FocusLost &&
(Required ?? false)
&& !(Disabled ?? false)
&& !ReadOnly
&& string.IsNullOrEmpty(CurrentValueAsString);
};
MessageCondition = CreateRequiredMessageCondition(() => string.IsNullOrEmpty(CurrentValueAsString));

}

Expand Down
7 changes: 6 additions & 1 deletion src/Core/Components/Radio/FluentRadioGroup.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ public partial class FluentRadioGroup<[DynamicallyAccessedMembers(DynamicallyAcc
/// <param name="configuration">The configuration settings used to initialize the radio group. This parameter cannot be null.</param>
[DynamicDependency(nameof(RadioChangeHandlerAsync))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(RadioEventArgs))]
public FluentRadioGroup(LibraryConfiguration configuration) : base(configuration) { }
public FluentRadioGroup(LibraryConfiguration configuration) : base(configuration)
{
MessageCondition = CreateRequiredMessageCondition(
() => !InternalRadios.Values.Any(radio => EqualityComparer<TValue?>.Default.Equals(radio.Value, Value)),
useFieldFocusLost: true);
}

/// <inheritdoc />
protected override string? StyleValue => DefaultStyleBuilder
Expand Down
2 changes: 2 additions & 0 deletions src/Core/Components/Switch/FluentSwitch.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public partial class FluentSwitch : FluentInputBase<bool>, ITooltipComponent, IF
public FluentSwitch(LibraryConfiguration configuration) : base(configuration)
{
LabelPosition ??= Components.LabelPosition.After;

MessageCondition = CreateRequiredMessageCondition(() => !Value);
}

/// <inheritdoc />
Expand Down
18 changes: 1 addition & 17 deletions src/Core/Components/TextArea/FluentTextArea.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,7 @@ public partial class FluentTextArea : FluentInputImmediateBase<string?>, IFluent
/// </summary>
public FluentTextArea(LibraryConfiguration configuration) : base(configuration)
{
// Default conditions for the message
MessageCondition = (field) =>
{
if (EditContext?.GetValidationMessages(FieldIdentifier).Any() == true)
{
return false;
}

field.MessageIcon = FluentStatus.ErrorIcon;
field.Message = Localizer[Localization.LanguageResource.TextInput_RequiredMessage];

return FocusLost &&
(Required ?? false)
&& !(Disabled ?? false)
&& !ReadOnly
&& string.IsNullOrEmpty(CurrentValueAsString);
};
MessageCondition = CreateRequiredMessageCondition(() => string.IsNullOrEmpty(CurrentValueAsString));
}

/// <inheritdoc />
Expand Down
18 changes: 1 addition & 17 deletions src/Core/Components/TextInput/FluentTextInput.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,7 @@ public partial class FluentTextInput : FluentInputImmediateBase<string?>, IFluen
/// </summary>
public FluentTextInput(LibraryConfiguration configuration) : base(configuration)
{
// Default conditions for the message
MessageCondition = (field) =>
{
if (EditContext?.GetValidationMessages(FieldIdentifier).Any() == true)
{
return false;
}

field.MessageIcon = FluentStatus.ErrorIcon;
field.Message = Localizer[Localization.LanguageResource.TextInput_RequiredMessage];

return FocusLost &&
(Required ?? false)
&& !(Disabled ?? false)
&& !ReadOnly
&& string.IsNullOrEmpty(CurrentValueAsString);
};
MessageCondition = CreateRequiredMessageCondition(() => string.IsNullOrEmpty(CurrentValueAsString));

}

Expand Down
Loading
Loading