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(); /// /// Child content of component. /// [Parameter] [Category(CategoryTypes.Form.ValidatedData)] public RenderFragment ChildContent { get; set; } /// /// Validation status. True if the form is valid and without errors. This parameter is two-way bindable. /// [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. /// /// True if any field of the field was touched. This parameter is readonly. /// [Parameter] [Category(CategoryTypes.Form.Behavior)] public bool IsTouched { get => _touched; set {/* readonly parameter! */ } } private bool _touched = false; /// /// 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". /// [Parameter] [Category(CategoryTypes.Form.Behavior)] public int ValidationDelay { get; set; } = 300; /// /// 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. /// [Parameter] [Category(CategoryTypes.Form.Behavior)] public bool SuppressRenderingOnValidation { get; set; } = false; /// /// When true, will not cause a page refresh on Enter if any input has focus. /// /// /// 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. /// [Parameter] [Category(CategoryTypes.Form.Behavior)] public bool SuppressImplicitSubmission { get; set; } = true; /// /// Raised when IsValid changes. /// [Parameter] public EventCallback IsValidChanged { get; set; } /// /// Raised when IsTouched changes. /// [Parameter] public EventCallback IsTouchedChanged { get; set; } /// /// Raised when a contained IFormComponent changes its value /// [Parameter] public EventCallback FieldChanged { get; set; } // keeps track of validation. if the input was validated at least once the value will be true protected HashSet _formControls = new(); protected HashSet _errors = new(); /// /// A default validation func or a validation attribute to use for form controls that don't have one. /// Supported types are: /// Func<T, bool> ... will output the standard error message "Invalid" if false /// Func<T, string> ... outputs the result as error message, no error if null /// Func<T, IEnumerable< string >> ... outputs all the returned error messages, no error if empty /// Func<object, string, IEnumerable< string >> input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty /// Func<T, Task< bool >> ... will output the standard error message "Invalid" if false /// Func<T, Task< string >> ... outputs the result as error message, no error if null /// Func<T, Task<IEnumerable< string >>> ... outputs all the returned error messages, no error if empty /// Func<object, string, Task<IEnumerable< string >>> input Form.Model, Full Path of Member ... outputs all the returned error messages, no error if empty /// System.ComponentModel.DataAnnotations.ValidationAttribute instances /// [Parameter] [Category(CategoryTypes.FormComponent.Validation)] public object Validation { get; set; } /// /// If a field already has a validation, override it with . /// [Parameter] [Category(CategoryTypes.FormComponent.Validation)] public bool? OverrideFieldValidation { get; set; } /// /// Validation error messages. /// [Parameter] [Category(CategoryTypes.Form.ValidationResult)] public string[] Errors { get => _errors.ToArray(); set { /* readonly */ } } [Parameter] public EventCallback ErrorsChanged { get; set; } /// /// Specifies the top-level model object for the form. Used with Fluent Validation /// #nullable enable [Parameter] [Category(CategoryTypes.Form.ValidatedData)] public object? Model { get; set; } #nullable disable private HashSet
ChildForms { get; set; } = new HashSet(); [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; /// /// Called by any input of the form to signal that its value changed. /// /// 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; } /// /// Force a validation of all form controls, even if they haven't been touched by the user yet. /// 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); } /// /// Reset all form controls and reset their validation state. /// public void Reset() { foreach (var control in _formControls.ToArray()) { control.Reset(); } foreach (var form in ChildForms) { form.Reset(); } EvaluateForm(debounce: false); } /// /// Reset the validation state but keep the values. /// 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(); } }