|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using Microsoft.AspNetCore.Components.Forms.Mapping;
namespace Microsoft.AspNetCore.Components.Forms;
/// <summary>
/// A base class for form input components. This base class automatically
/// integrates with an <see cref="Forms.EditContext"/>, which must be supplied
/// as a cascading parameter.
/// </summary>
public abstract class InputBase<TValue> : ComponentBase, IDisposable
{
private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;
private bool _hasInitializedParameters;
private bool _parsingFailed;
private string? _incomingValueBeforeParsing;
private string? _formattedValueExpression;
private bool _previousParsingAttemptFailed;
private ValidationMessageStore? _parsingValidationMessages;
private Type? _nullableUnderlyingType;
private bool _shouldGenerateFieldNames;
[CascadingParameter] private EditContext? CascadedEditContext { get; set; }
[CascadingParameter] private HtmlFieldPrefix FieldPrefix { get; set; } = default!;
/// <summary>
/// Gets or sets a collection of additional attributes that will be applied to the created element.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }
/// <summary>
/// Gets or sets the value of the input. This should be used with two-way binding.
/// </summary>
/// <example>
/// @bind-Value="model.PropertyName"
/// </example>
[Parameter]
public TValue? Value { get; set; }
/// <summary>
/// Gets or sets a callback that updates the bound value.
/// </summary>
[Parameter] public EventCallback<TValue> ValueChanged { get; set; }
/// <summary>
/// Gets or sets an expression that identifies the bound value.
/// </summary>
[Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; }
/// <summary>
/// Gets or sets the display name for this field.
/// <para>This value is used when generating error messages when the input value fails to parse correctly.</para>
/// </summary>
[Parameter] public string? DisplayName { get; set; }
/// <summary>
/// Gets the associated <see cref="Forms.EditContext"/>.
/// This property is uninitialized if the input does not have a parent <see cref="EditForm"/>.
/// </summary>
protected EditContext EditContext { get; set; } = default!;
/// <summary>
/// Gets the <see cref="FieldIdentifier"/> for the bound value.
/// </summary>
protected internal FieldIdentifier FieldIdentifier { get; set; }
/// <summary>
/// Gets or sets the current value of the input.
/// </summary>
protected TValue? CurrentValue
{
get => Value;
set
{
var hasChanged = !EqualityComparer<TValue>.Default.Equals(value, Value);
if (hasChanged)
{
_parsingFailed = false;
// If we don't do this, then when the user edits from A to B, we'd:
// - Do a render that changes back to A
// - Then send the updated value to the parent, which sends the B back to this component
// - Do another render that changes it to B again
// The unnecessary reversion from B to A can cause selection to be lost while typing
// A better solution would be somehow forcing the parent component's render to occur first,
// but that would involve a complex change in the renderer to keep the render queue sorted
// by component depth or similar.
Value = value;
_ = ValueChanged.InvokeAsync(Value);
EditContext?.NotifyFieldChanged(FieldIdentifier);
}
}
}
/// <summary>
/// Gets or sets the current value of the input, represented as a string.
/// </summary>
protected string? CurrentValueAsString
{
// InputBase-derived components can hold invalid states (e.g., an InputNumber being blank even when bound
// to an int value). So, if parsing fails, we keep the rejected string in the UI even though it doesn't
// match what's on the .NET model. This avoids interfering with typing, but still notifies the EditContext
// about the validation error message.
get => _parsingFailed ? _incomingValueBeforeParsing : FormatValueAsString(CurrentValue);
set
{
_incomingValueBeforeParsing = value;
_parsingValidationMessages?.Clear();
if (_nullableUnderlyingType != null && string.IsNullOrEmpty(value))
{
// Assume if it's a nullable type, null/empty inputs should correspond to default(T)
// Then all subclasses get nullable support almost automatically (they just have to
// not reject Nullable<T> based on the type itself).
_parsingFailed = false;
CurrentValue = default!;
}
else if (TryParseValueFromString(value, out var parsedValue, out var validationErrorMessage))
{
_parsingFailed = false;
CurrentValue = parsedValue!;
}
else
{
_parsingFailed = true;
// EditContext may be null if the input is not a child component of EditForm.
if (EditContext is not null)
{
_parsingValidationMessages ??= new ValidationMessageStore(EditContext);
_parsingValidationMessages.Add(FieldIdentifier, validationErrorMessage);
// Since we're not writing to CurrentValue, we'll need to notify about modification from here
EditContext.NotifyFieldChanged(FieldIdentifier);
}
}
// We can skip the validation notification if we were previously valid and still are
if (_parsingFailed || _previousParsingAttemptFailed)
{
EditContext?.NotifyValidationStateChanged();
_previousParsingAttemptFailed = _parsingFailed;
}
}
}
/// <summary>
/// Constructs an instance of <see cref="InputBase{TValue}"/>.
/// </summary>
protected InputBase()
{
_validationStateChangedHandler = OnValidateStateChanged;
}
/// <summary>
/// Formats the value as a string. Derived classes can override this to determine the formatting used for <see cref="CurrentValueAsString"/>.
/// </summary>
/// <param name="value">The value to format.</param>
/// <returns>A string representation of the value.</returns>
protected virtual string? FormatValueAsString(TValue? value)
=> value?.ToString();
/// <summary>
/// Parses a string to create an instance of <typeparamref name="TValue"/>. Derived classes can override this to change how
/// <see cref="CurrentValueAsString"/> interprets incoming values.
/// </summary>
/// <param name="value">The string value to be parsed.</param>
/// <param name="result">An instance of <typeparamref name="TValue"/>.</param>
/// <param name="validationErrorMessage">If the value could not be parsed, provides a validation error message.</param>
/// <returns>True if the value could be parsed; otherwise false.</returns>
protected abstract bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage);
/// <summary>
/// Gets a CSS class string that combines the <c>class</c> attribute and a string indicating
/// the status of the field being edited (a combination of "modified", "valid", and "invalid").
/// Derived components should typically use this value for the primary HTML element's 'class' attribute.
/// </summary>
protected string CssClass
{
get
{
var fieldClass = EditContext?.FieldCssClass(FieldIdentifier);
return AttributeUtilities.CombineClassNames(AdditionalAttributes, fieldClass) ?? string.Empty;
}
}
/// <summary>
/// Gets the value to be used for the input's "name" attribute.
/// </summary>
protected string NameAttributeValue
{
get
{
if (AdditionalAttributes?.TryGetValue("name", out var nameAttributeValue) ?? false)
{
return Convert.ToString(nameAttributeValue, CultureInfo.InvariantCulture) ?? string.Empty;
}
if (_shouldGenerateFieldNames)
{
if (_formattedValueExpression is null && ValueExpression is not null)
{
_formattedValueExpression = FieldPrefix != null ? FieldPrefix.GetFieldName(ValueExpression) :
ExpressionFormatter.FormatLambda(ValueExpression);
}
return _formattedValueExpression ?? string.Empty;
}
return string.Empty;
}
}
/// <inheritdoc />
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
if (!_hasInitializedParameters)
{
// This is the first run
// Could put this logic in OnInit, but its nice to avoid forcing people who override OnInit to call base.OnInit()
if (ValueExpression == null)
{
throw new InvalidOperationException($"{GetType()} requires a value for the 'ValueExpression' " +
$"parameter. Normally this is provided automatically when using 'bind-Value'.");
}
FieldIdentifier = FieldIdentifier.Create(ValueExpression);
if (CascadedEditContext != null)
{
EditContext = CascadedEditContext;
EditContext.OnValidationStateChanged += _validationStateChangedHandler;
_shouldGenerateFieldNames = EditContext.ShouldUseFieldIdentifiers;
}
else
{
// Ideally we'd know if we were in an SSR context but we don't
_shouldGenerateFieldNames = !OperatingSystem.IsBrowser();
}
_nullableUnderlyingType = Nullable.GetUnderlyingType(typeof(TValue));
_hasInitializedParameters = true;
}
else if (CascadedEditContext != EditContext)
{
// Not the first run
// We don't support changing EditContext because it's messy to be clearing up state and event
// handlers for the previous one, and there's no strong use case. If a strong use case
// emerges, we can consider changing this.
throw new InvalidOperationException($"{GetType()} does not support changing the " +
$"{nameof(Forms.EditContext)} dynamically.");
}
UpdateAdditionalValidationAttributes();
// For derived components, retain the usual lifecycle with OnInit/OnParametersSet/etc.
return base.SetParametersAsync(ParameterView.Empty);
}
private void OnValidateStateChanged(object? sender, ValidationStateChangedEventArgs eventArgs)
{
UpdateAdditionalValidationAttributes();
StateHasChanged();
}
private void UpdateAdditionalValidationAttributes()
{
if (EditContext is null)
{
return;
}
var hasAriaInvalidAttribute = AdditionalAttributes != null && AdditionalAttributes.ContainsKey("aria-invalid");
if (EditContext.GetValidationMessages(FieldIdentifier).Any())
{
// If this input is associated with an incoming value from an HTTP form post (via model binding),
// retain the attempted value even if it's unparseable
var attemptedValue = EditContext.GetAttemptedValue(NameAttributeValue);
if (attemptedValue != null)
{
_parsingFailed = true;
_incomingValueBeforeParsing = attemptedValue;
}
if (hasAriaInvalidAttribute)
{
// Do not overwrite the attribute value
return;
}
if (ConvertToDictionary(AdditionalAttributes, out var additionalAttributes))
{
AdditionalAttributes = additionalAttributes;
}
// To make the `Input` components accessible by default
// we will automatically render the `aria-invalid` attribute when the validation fails
// value must be "true" see https://www.w3.org/TR/wai-aria-1.1/#aria-invalid
additionalAttributes["aria-invalid"] = "true";
}
else if (hasAriaInvalidAttribute)
{
// No validation errors. Need to remove `aria-invalid` if it was rendered already
if (AdditionalAttributes!.Count == 1)
{
// Only aria-invalid argument is present which we don't need any more
AdditionalAttributes = null;
}
else
{
if (ConvertToDictionary(AdditionalAttributes, out var additionalAttributes))
{
AdditionalAttributes = additionalAttributes;
}
additionalAttributes.Remove("aria-invalid");
}
}
}
/// <summary>
/// Returns a dictionary with the same values as the specified <paramref name="source"/>.
/// </summary>
/// <returns>true, if a new dictionary with copied values was created. false - otherwise.</returns>
private static bool ConvertToDictionary(IReadOnlyDictionary<string, object>? source, out Dictionary<string, object> result)
{
var newDictionaryCreated = true;
if (source == null)
{
result = new Dictionary<string, object>();
}
else if (source is Dictionary<string, object> currentDictionary)
{
result = currentDictionary;
newDictionaryCreated = false;
}
else
{
result = new Dictionary<string, object>();
foreach (var item in source)
{
result.Add(item.Key, item.Value);
}
}
return newDictionaryCreated;
}
/// <inheritdoc/>
protected virtual void Dispose(bool disposing)
{
}
void IDisposable.Dispose()
{
// When initialization in the SetParametersAsync method fails, the EditContext property can remain equal to null
if (EditContext is not null)
{
EditContext.OnValidationStateChanged -= _validationStateChangedHandler;
}
// Clear parsing validation messages store owned by the input when the input is disposed.
if (_parsingValidationMessages != null)
{
_parsingValidationMessages.Clear();
EditContext!.NotifyValidationStateChanged(); // when _parsingValidationMessages is not null, EditContext is also not null.
}
Dispose(disposing: true);
}
}
|