Automatically generate a form from an object in Blazor

 
 
  • Gérald Barré

Blazor provides building blocks for creating forms. It includes editor components, model validation, and model binding. When you want to create a form, you need to create an object to store the form data and create the razor component with labels and editors for each property. This is tedious when you want to quickly create a basic form.

To speed up this process, I've created a generic form component. This component automatically creates a form from an object. So, when you need a basic form, it works out of the box.

csproj (MSBuild project file)
<Project Sdk="Microsoft.NET.Sdk.Web">
  ...
  <ItemGroup>
    <PackageReference Include="Meziantou.AspNetCore.Components" Version="1.0.2" />
  </ItemGroup>
  ...
</Project>
Razor
@using Meziantou.AspNetCore.Components
@using System.ComponentModel.DataAnnotations

<h1>Demo!</h1>

<EditForm Model="NewPerson" OnSubmit="() => { }">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <GenericForm Model="NewPerson" />

    <button type="submit">Submit</button>
</EditForm>

@NewPerson

@code {
    Person NewPerson { get; set; } = new Person();

    public record Person
    {
        public string Name { get; set; } = "Gérald Barré";

        public string Nickname { get; set; } = "meziantou";

        // It supports DataAnnotations (DataType, Display, DisplayName, Editable, Editor, etc.)
        [DataType(DataType.Url)]
        public string Website { get; set; } = "https://www.meziantou.net";

        [DataType(DataType.Date)]
        public DateTime DateOfBirth { get; set; }

        public int Children { get; set; }
    }
}

This code generates the following page:

It's possible to customize it for bootstrap or any other framework using a template:

Razor
<EditForm Model="NewPerson" OnSubmit="() => { }">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <GenericForm Model="NewPerson" EditorClass="form-control">
        <FieldTemplate Context="field">
            <div class="form-group">
                <label for="@field.EditorId">@field.DisplayName</label>
                @field.EditorTemplate
                @field.FieldValidationTemplate
            </div>
        </FieldTemplate>
    </GenericForm>

    <button type="submit" class="btn btn-primary">Submit</button>
</EditForm>

#How does it work

The idea is to iterate on the object's properties and create an InputXXX component for each property based on the property type. While this looks very simple, Razor does many things that are not trivial when you have to do them yourself. Indeed, when you bind a value to an Input component, Razor generates 3 properties:

  • TValue Value to set the current property value
  • EventCallback<TValue> ValueChanged to be notified when the html input value changed (<input onchange="">), so you can update the model value
  • Expression<Func<TValue>> ValueExpression to compute the field name for the validation context

Let's start by iterating the writable properties:

C#
public partial class GenericFormField<TModel>
{
    private readonly GenericForm<TModel> _form;

    public PropertyInfo Property { get; }

    private GenericFormField(GenericForm<TModel> form, PropertyInfo propertyInfo)
    {
        _form = form;
        Property = propertyInfo;
    }

    internal static List<GenericFormField<TModel>> Create(GenericForm<TModel> form)
    {
        var result = new List<GenericFormField<TModel>>();
        var properties = typeof(TModel).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy);
        foreach (var prop in properties)
        {
            if (prop.SetMethod == null) // Skip readonly properties
                continue;

            result.Add(new GenericFormField<TModel>(form, prop));
        }

        return result;
    }
}

Then, we can expose some properties used to render the field (label and input):

C#
public partial class GenericFormField<TModel>
{
    public Type PropertyType => Property.PropertyType;
    public string EditorId => _form.BaseEditorId + '_' + Property.Name;
    public TModel Owner => _form.Model;

    public string DisplayName
    {
        get
        {
            var displayName = Property.GetCustomAttribute<DisplayAttribute>()?.GetName();
            if (!string.IsNullOrEmpty(displayName))
                return displayName;

            return Property.Name;
        }
    }

    public object Value
    {
        get => Property.GetValue(Owner);
        set => Property.SetValue(Owner, value);
    }
}

Then, we can find the right Input component based on the property type:

C#
public partial class GenericFormField<TModel>
{
    // The actual logic is a little bit more complicated as it also checks the DataType attribute
    private static Type GetEditorType(PropertyInfo property)
    {
        var editorAttributes = property.GetCustomAttributes<EditorAttribute>();
        foreach (var editorAttribute in editorAttributes)
        {
            if (editorAttribute.EditorBaseTypeName == typeof(InputBase<>).AssemblyQualifiedName)
                return Type.GetType(editorAttribute.EditorTypeName, throwOnError: true)!;
        }

        if (property.PropertyType == typeof(bool))
            return typeof(InputCheckbox);

        if (property.PropertyType == typeof(string))
            return typeof(InputText);

        if (property.PropertyType == typeof(int))
            return (typeof(InputNumber<int>), null);

        // ...

        return (typeof(InputText), null);
    }
}

And now we can do the hard work:

C#
public partial class GenericFormField<TModel>
{
    public RenderFragment EditorTemplate
    {
        get
        {
            return builder =>
            {
                // 1. Create the value for "ValueExpression" property
                // Expression<Func<T>>: () => Owner.Property
                var access = Expression.Property(Expression.Constant(Owner, typeof(TModel)), Property);
                var lambda = Expression.Lambda(typeof(Func<>).MakeGenericType(PropertyType), access);

                // 2. Create the value for "ValueChanged" property
                // Expression<Action<T>>: value => this.Value = (object)value;
                var changeHandlerParameter = Expression.Parameter(PropertyType);
                var body = Expression.Assign(Expression.Property(Expression.Constant(this), nameof(Value)), Expression.Convert(changeHandlerParameter, typeof(object)));
                var changeHandlerLambda = Expression.Lambda(typeof(Action<>).MakeGenericType(PropertyType), body, changeHandlerParameter);

                // Create the handler from the expression using EventCallbackFactory.Create<T>(object receiver, Action<T> callback)
                var method = s_eventCallbackFactoryCreate.MakeGenericMethod(PropertyType);
                var changeHandler = method.Invoke(EventCallback.Factory, new object[] { this, changeHandlerLambda.Compile() });

                // 3. Create the RenderFragment with the component
                var componentType = GetEditorType(Property);
                builder.OpenComponent(0, componentType);
                builder.AddAttribute(1, "Value", Value);
                builder.AddAttribute(2, "ValueChanged", changeHandler);
                builder.AddAttribute(3, "ValueExpression", lambda);
                builder.AddAttribute(4, "id", EditorId);
                builder.AddAttribute(5, "class", _form.EditorClass);
                builder.CloseComponent();
            };
        }
    }

    public RenderFragment? FieldValidationTemplate
    {
        get
        {
            if (!_form.EnableFieldValidation)
                return null;

            return builder =>
            {
                // 1. Create the value for "ValueExpression" property
                // Expression<Func<T>>: () => Owner.Property
                var access = Expression.Property(Expression.Constant(Owner, typeof(TModel)), Property);
                var lambda = Expression.Lambda(typeof(Func<>).MakeGenericType(PropertyType), access);

                // 2. Render the component
                builder.OpenComponent(0, typeof(ValidationMessage<>).MakeGenericType(PropertyType));
                builder.AddAttribute(1, "For", lambda);
                builder.CloseComponent();
            };
        }
    }
}

Last, we can create the Blazor component by creating a file named GenericForm.razor with the following content:

Razor
@* file GenericForm.razor *@
@typeparam TModel

@if (fields != null)
{
    foreach (var field in fields)
    {
        if(FieldTemplate != null)
        {
            @FieldTemplate(field)
        }
        else
        {
            <div>
                <label for="@field.EditorId">@field.DisplayName</label>
                @field.EditorTemplate
                @field.FieldValidationTemplate
            </div>
        }
    }
}

@code{
    internal string BaseEditorId { get; } = Guid.NewGuid().ToString();
    private List<GenericFormField<TModel>>? fields;

    [Parameter]
    public TModel? Model { get; set; }

    [Parameter]
    public bool EnableFieldValidation { get; set; } = true;

    [Parameter]
    public string? EditorClass { get; set; }

    [Parameter]
    public RenderFragment<GenericFormField<TModel>>? FieldTemplate { get; set; }

    protected override void OnParametersSet()
    {
        base.OnParametersSet();
        fields = Model == null ? null : GenericFormField<TModel>.Create(this);
    }
}

You can now run the code from the beginning!

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub