Automatically generate a form from an object in Blazor

 
 
  • Gérald Barré

Blazor provides building blocks for creating forms, including editor components, model validation, and model binding. When creating a form, you need to define an object to store the form data and build a Razor component with labels and editors for each property. This becomes tedious when you just need a simple form quickly.

To speed up this process, I created a generic form component that automatically builds a form from an object. For basic forms, 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:

You can 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 over the object's properties and create an InputXXX component for each one based on its type. While this sounds simple, Razor does several things that are non-trivial to replicate manually. When you bind a value to an Input component, Razor generates three 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 over 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;
    }
}

Next, we expose some properties used to render each 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);
    }
}

Next, we select 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();
            };
        }
    }
}

Finally, we create the Blazor component by adding 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?