You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Connected.Components/Components/Form/Form.razor.cs

327 lines
9.4 KiB

using Connected.Annotations;
using Connected.Utilities;
using Microsoft.AspNetCore.Components;
namespace Connected.Components;
public partial class Form : UIComponent, IDisposable, IForm
{
protected string Classname =>
new CssBuilder("mud-form")
.AddClass(Class)
.Build();
/// <summary>
/// Child content of component.
/// </summary>
[Parameter]
[Category(CategoryTypes.Form.ValidatedData)]
public RenderFragment ChildContent { get; set; }
/// <summary>
/// Validation status. True if the form is valid and without errors. This parameter is two-way bindable.
/// </summary>
[Parameter]
[Category(CategoryTypes.Form.ValidationResult)]
public bool IsValid
{
get => _valid && ChildForms.All(x => x.IsValid);
set
{
_valid = value;
}
}
// Note: w/o any children the form is automatically valid.
// It stays valid, as long as non-required fields are added or
// a required field is added or the user touches a field that fails validation.
private bool _valid = true;
private void SetIsValid(bool value)
{
if (IsValid == value)
return;
IsValid = value;
IsValidChanged.InvokeAsync(IsValid).AndForget();
}
// Note: w/o any children the form is automatically valid.
// It stays valid, as long as non-required fields are added or
// a required field is added or the user touches a field that fails validation.
/// <summary>
/// True if any field of the field was touched. This parameter is readonly.
/// </summary>
[Parameter]
[Category(CategoryTypes.Form.Behavior)]
public bool IsTouched { get => _touched; set {/* readonly parameter! */ } }
private bool _touched = false;
/// <summary>
/// Validation debounce delay in milliseconds. This can help improve rendering performance of forms with real-time validation of inputs
/// i.e. when textfields have Immediate="true".
/// </summary>
[Parameter]
[Category(CategoryTypes.Form.Behavior)]
public int ValidationDelay { get; set; } = 300;
/// <summary>
/// When true, the form will not re-render its child contents on validation updates (i.e. when IsValid changes).
/// This is an optimization which can be necessary especially for larger forms on older devices.
/// </summary>
[Parameter]
[Category(CategoryTypes.Form.Behavior)]
public bool SuppressRenderingOnValidation { get; set; } = false;
/// <summary>
/// When true, will not cause a page refresh on Enter if any input has focus.
/// </summary>
/// <remarks>
/// https://www.w3.org/TR/2018/SPSD-html5-20180327/forms.html#implicit-submission
/// Usually this is not wanted, as it can cause a page refresh in the middle of editing a form.
/// When the form is in a dialog this will cause the dialog to close. So by default we suppress it.
/// </remarks>
[Parameter]
[Category(CategoryTypes.Form.Behavior)]
public bool SuppressImplicitSubmission { get; set; } = true;
/// <summary>
/// Raised when IsValid changes.
/// </summary>
[Parameter] public EventCallback<bool> IsValidChanged { get; set; }
/// <summary>
/// Raised when IsTouched changes.
/// </summary>
[Parameter] public EventCallback<bool> IsTouchedChanged { get; set; }
/// <summary>
/// Raised when a contained IFormComponent changes its value
/// </summary>
[Parameter] public EventCallback<FormFieldChangedEventArgs> FieldChanged { get; set; }
// keeps track of validation. if the input was validated at least once the value will be true
protected HashSet<IFormComponent> _formControls = new();
protected HashSet<string> _errors = new();
/// <summary>
/// A default validation func or a validation attribute to use for form controls that don't have one.
/// Supported types are:
/// <para>Func&lt;T, bool&gt; ... will output the standard error message "Invalid" if false</para>
/// <para>Func&lt;T, string&gt; ... outputs the result as error message, no error if null </para>
/// <para>Func&lt;T, IEnumerable&lt; string &gt;&gt; ... outputs all the returned error messages, no error if empty</para>
/// <para>Func&lt;object, string, IEnumerable&lt; string &gt;&gt; input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty</para>
/// <para>Func&lt;T, Task&lt; bool &gt;&gt; ... will output the standard error message "Invalid" if false</para>
/// <para>Func&lt;T, Task&lt; string &gt;&gt; ... outputs the result as error message, no error if null</para>
/// <para>Func&lt;T, Task&lt;IEnumerable&lt; string &gt;&gt;&gt; ... outputs all the returned error messages, no error if empty</para>
/// <para>Func&lt;object, string, Task&lt;IEnumerable&lt; string &gt;&gt;&gt; input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty</para>
/// <para>System.ComponentModel.DataAnnotations.ValidationAttribute instances</para>
/// </summary>
[Parameter]
[Category(CategoryTypes.FormComponent.Validation)]
public object Validation { get; set; }
/// <summary>
/// If a field already has a validation, override it with <see cref="Validation"/>.
/// </summary>
[Parameter]
[Category(CategoryTypes.FormComponent.Validation)]
public bool? OverrideFieldValidation { get; set; }
/// <summary>
/// Validation error messages.
/// </summary>
[Parameter]
[Category(CategoryTypes.Form.ValidationResult)]
public string[] Errors
{
get => _errors.ToArray();
set { /* readonly */ }
}
[Parameter] public EventCallback<string[]> ErrorsChanged { get; set; }
/// <summary>
/// Specifies the top-level model object for the form. Used with Fluent Validation
/// </summary>
#nullable enable
[Parameter]
[Category(CategoryTypes.Form.ValidatedData)]
public object? Model { get; set; }
#nullable disable
private HashSet<Form> ChildForms { get; set; } = new HashSet<Form>();
[CascadingParameter] private Form ParentForm { get; set; }
void IForm.FieldChanged(IFormComponent formControl, object newValue)
{
FieldChanged.InvokeAsync(new FormFieldChangedEventArgs { Field = formControl, NewValue = newValue }).AndForget();
}
void IForm.Add(IFormComponent formControl)
{
if (formControl.Required)
SetIsValid(false);
_formControls.Add(formControl);
SetDefaultControlValidation(formControl);
}
void IForm.Remove(IFormComponent formControl)
{
_formControls.Remove(formControl);
}
private Timer _timer;
/// <summary>
/// Called by any input of the form to signal that its value changed.
/// </summary>
/// <param name="formControl"></param>
void IForm.Update(IFormComponent formControl)
{
EvaluateForm();
}
private void EvaluateForm(bool debounce = true)
{
_timer?.Dispose();
if (debounce && ValidationDelay > 0)
_timer = new Timer(OnTimerComplete, null, ValidationDelay, Timeout.Infinite);
else
_ = OnEvaluateForm();
}
private void OnTimerComplete(object stateInfo) => InvokeAsync(OnEvaluateForm);
private bool _shouldRender = true; // <-- default is true, we need the form children to render
protected async Task OnEvaluateForm()
{
_errors.Clear();
foreach (var error in _formControls.SelectMany(control => control.ValidationErrors))
_errors.Add(error);
// form can only be valid if:
// - none have an error
// - all required fields have been touched (and thus validated)
var no_errors = _formControls.All(x => x.HasErrors == false);
var required_all_touched = _formControls.Where(x => x.Required).All(x => x.Modified);
var valid = no_errors && required_all_touched;
var old_touched = _touched;
_touched = _formControls.Any(x => x.Modified);
try
{
_shouldRender = false;
SetIsValid(valid);
await ErrorsChanged.InvokeAsync(Errors);
if (old_touched != _touched)
await IsTouchedChanged.InvokeAsync(_touched);
}
finally
{
_shouldRender = true;
}
}
protected override bool ShouldRender()
{
if (!SuppressRenderingOnValidation)
return true;
return _shouldRender;
}
/// <summary>
/// Force a validation of all form controls, even if they haven't been touched by the user yet.
/// </summary>
public async Task Validate()
{
await Task.WhenAll(_formControls.Select(x => x.Validate()));
if (ChildForms.Count > 0)
{
await Task.WhenAll(ChildForms.Select(x => x.Validate()));
}
EvaluateForm(debounce: false);
}
/// <summary>
/// Reset all form controls and reset their validation state.
/// </summary>
public void Reset()
{
foreach (var control in _formControls.ToArray())
{
control.Reset();
}
foreach (var form in ChildForms)
{
form.Reset();
}
EvaluateForm(debounce: false);
}
/// <summary>
/// Reset the validation state but keep the values.
/// </summary>
public void ResetValidation()
{
foreach (var control in _formControls.ToArray())
{
control.ResetValidation();
}
foreach (var form in ChildForms)
{
form.ResetValidation();
}
EvaluateForm(debounce: false);
}
protected override Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var valid = _formControls.All(x => x.Required == false);
if (valid != IsValid)
{
// the user probably bound a variable to IsValid and it conflicts with our state.
// let's set this right
SetIsValid(valid);
}
}
return base.OnAfterRenderAsync(firstRender);
}
private void SetDefaultControlValidation(IFormComponent formComponent)
{
if (Validation == null) return;
if (!formComponent.IsForNull && (formComponent.Validation == null || (OverrideFieldValidation ?? true)))
{
formComponent.Validation = Validation;
}
}
protected override void OnInitialized()
{
if (ParentForm != null)
{
ParentForm.ChildForms.Add(this);
}
base.OnInitialized();
}
public void Dispose()
{
_timer?.Dispose();
}
}