Skip to content

Latest commit

 

History

History
695 lines (546 loc) · 26.6 KB

File metadata and controls

695 lines (546 loc) · 26.6 KB

FormCraft 🎨

NuGet Version NuGet Downloads MudBlazor Version Build Status License Stars

Build type-safe, dynamic forms in Blazor with ease

Get StartedLive DemoDocumentationExamplesContributing


🌐 Live Demo

Experience FormCraft in action! Visit our interactive demo to see:

  • 🎯 Various form layouts and configurations
  • 🔄 Dynamic field dependencies
  • ✨ Custom field renderers
  • 📤 File upload capabilities
  • 🎨 Real-time form generation

🎉 What's New in v3.1.0

v3.1.0 implements every issue that was open after v3.0 — all features, no breaking changes. Full changelog →

  • Zero-config formsAddFieldsAuto() generates a complete form from any POCO by reflection: humanized labels, sensible field types per property type, DataAnnotations honored when present, none required (#124)
  • Security enforcementWithSecurity() is now enforced automatically by FormCraftComponent: rate limiting (with SecurityContextId parameter), CSRF validation, and FormSubmitted/FormRejected audit entries with redaction; plus EncryptConfiguredFields() for one-call persistence encryption (#147)
  • Configurable MudBlazor Variant.WithVariant(Variant.Filled) per field and a DefaultVariant parameter on FormCraftComponent, honored by every input component (#146)
  • Async field dependenciesDependsOn(x => x.Country, async (model, country) => ...) is a first-class overload; cascades re-render automatically when the async work settles (#93)
  • Nullable value types round-tripint?/decimal?/DateTime?/DateOnly?/TimeOnly? fields display empty when null and write null back when cleared, instead of being coerced to 0/MinValue (#150)
  • Native nested validation for collections — collection item edits raise Items[0].ProductName field identifiers on the EditContext, so ValidationSummary/IsModified work for child rows (#91)
  • Single render pipeline — the legacy type-switch is gone; every field flows through FieldRendererService, and AsMultiSelect fields (previously skipped silently) now render a real multi-selection select (#148)
  • Master-detail & auto-form demos — new /master-detail (invoice + LOV customer + line items + computed totals) and /auto-form pages (#130)
  • Polish — single-file uploads no longer emit a stray multiple attribute (#149), Related Demos show real titles (#152), WithAutocomplete() + correct password autocomplete tokens (#153), validator mutations through the object-typed wrapper now take effect via AddValidator (#151)

🎉 What's New in v3.0.0

v3.0.0 is a major quality release: after a full audit of every subsystem, 60+ bugs were fixed, several long-broken features now actually work, and the whole demo site was verified end-to-end in a real browser. Full changelog →

✅ Now working as documented

  • Field dependenciesDependsOn(x => x.Country, ...) callbacks now fire when the watched field changes, dependent fields refresh in the UI, and async callbacks drive cascading loads (country → state → city)
  • Async validation blocks submissionOnValidSubmit waits for async validators; errors clear as soon as the user corrects a value; hidden fields are no longer validated
  • Custom renderingWithCustomTemplate() renders, WithCustomRenderer(instance) is honored, and LOV/lookup/autocomplete/select renderers are no longer shadowed by the generic text/numeric ones
  • More field typesDateOnly, TimeOnly, float, long, short, and byte fields render correctly
  • Form templatesFormTemplates.ContactForm/RegistrationForm/LoginForm/AddressForm<T>() generate real convention-based forms
  • New APIFormCraftComponent.ValidateAsync() for explicit validation (e.g. in dialogs)

🔒 Security hardening

  • Default IEncryptionService is now AES-256 (DefaultEncryptionService) with a random IV per operation; decryption failures throw FormCraftDecryptionException instead of returning ciphertext
  • Thread-safe rate limiting, CSRF tokens that survive prerendering, and audit logs that honor ExcludedFields redaction

⚠️ Breaking changes (migration notes)

Change What to do
FieldDependencies is keyed by the watched field's name Only affects code inspecting the configuration dictionary directly
DependsOn callbacks now fire on watched-field changes Remove any workarounds written for the old inverted behavior
FormBuilder throws if mutated after Build() Create a new builder instead of reusing one
WithFluentValidation fails when no IValidator<TModel> is registered Register your validator in DI (it silently passed before)
Validator exceptions surface as "Validation could not be completed: …" Don't rely on crashes producing the configured error message
AsFileUpload/AsMultipleFileUpload no longer force a renderer No action — the proper upload components are picked by field type
Default encryption switched from XOR to AES-256 Configure a 32-byte key (Base64 or UTF-8); without one an ephemeral per-process key is used
FieldGroupBuilder.WithColumns validates its range (1–6) Fix any out-of-range values (0 used to crash rendering)
FormCraft.ForMudBlazor now versions with the core package Reference 3.0.0 for both packages

🚀 Why FormCraft?

FormCraft revolutionizes form building in Blazor applications by providing a fluent, type-safe API that makes complex forms simple. Say goodbye to repetitive form markup and hello to elegant, maintainable code.

✨ Key Features

  • 🔒 Type-Safe - Full IntelliSense support with compile-time validation
  • 🎯 Fluent API - Intuitive method chaining for readable form configuration
  • 🏷️ Attribute-Based Forms - Generate forms from model attributes with zero configuration
  • 🎨 MudBlazor Integration - Beautiful Material Design components out of the box
  • 🔄 Dynamic Forms - Create forms that adapt based on user input
  • Advanced Validation - Built-in, custom, and async validators
  • 🔗 Field Dependencies - Link fields together with reactive updates
  • 📐 Flexible Layouts - Multiple layout options to fit your design
  • 🚀 High Performance - Optimized rendering with minimal overhead
  • 🧪 Fully Tested - 880+ unit tests ensuring reliability

📊 How FormCraft Compares

FormCraft stands out among Blazor form solutions with its type-safe fluent API, automatic field rendering, and built-in field dependency management. See how it compares to Blazor EditForm, Blazored.FluentValidation, and MudBlazor Forms:

Capability EditForm MudBlazor Forms FormCraft
Fluent API configuration - - Yes
Automatic field rendering - - Yes
Built-in field dependencies Manual Manual Yes
Conditional visibility Manual Manual Built-in
Field-level encryption - - Yes
Attribute-based generation - - Yes

View the full comparison — includes detailed feature matrix, code examples, and guidance on when to use each solution.

📦 Installation

FormCraft Core

dotnet add package FormCraft

FormCraft for MudBlazor

dotnet add package FormCraft.ForMudBlazor

Note: FormCraft.ForMudBlazor includes FormCraft as a dependency, so you only need to install the MudBlazor package if you're using MudBlazor components.

Supported frameworks: .NET 8, .NET 9, and .NET 10.

🎯 Quick Start

1. Register Services

// Program.cs
builder.Services.AddMudServices();          // MudBlazor services
builder.Services.AddFormCraft();            // FormCraft core services
builder.Services.AddFormCraftMudBlazor();   // MudBlazor renderers for FormCraft

2. Create Your Model

public class UserRegistration
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public int Age { get; set; }
    public string Country { get; set; }
    public bool AcceptTerms { get; set; }
}

3. Build Your Form

@page "/register"
@using FormCraft
@using FormCraft.ForMudBlazor

<h3>User Registration</h3>

<FormCraftComponent TModel="UserRegistration" 
                   Model="@model" 
                   Configuration="@formConfig"
                   OnValidSubmit="@HandleSubmit"
                   ShowSubmitButton="true" />

@code {
    private UserRegistration model = new();
    private IFormConfiguration<UserRegistration> formConfig;

    protected override void OnInitialized()
    {
        formConfig = FormBuilder<UserRegistration>.Create()
            .AddRequiredTextField(x => x.FirstName, "First Name")
            .AddRequiredTextField(x => x.LastName, "Last Name")
            .AddEmailField(x => x.Email)
            .AddNumericField(x => x.Age, "Age", min: 18, max: 120)
            .AddDropdownField(x => x.Country, "Country",
                ("us", "United States"),
                ("uk", "United Kingdom"),
                ("ca", "Canada"),
                ("au", "Australia"))
            .AddField(x => x.AcceptTerms, field => field
                .WithLabel("I accept the terms and conditions")
                .Required("You must accept the terms"))
            .Build();
    }

    private async Task HandleSubmit(UserRegistration model)
    {
        // Handle form submission
        await UserService.RegisterAsync(model);
    }
}

🏷️ Attribute-Based Forms (NEW!)

Define your forms directly on your model with attributes - no configuration code needed!

Define Your Model with Attributes

public class UserRegistration
{
    [TextField("First Name", "Enter your first name")]
    [Required(ErrorMessage = "First name is required")]
    [MinLength(2)]
    public string FirstName { get; set; } = string.Empty;
    
    [TextField("Last Name", "Enter your last name")]
    [Required(ErrorMessage = "Last name is required")]
    public string LastName { get; set; } = string.Empty;
    
    [EmailField("Email Address")]
    [Required]
    public string Email { get; set; } = string.Empty;
    
    [NumberField("Age", "Your age")]
    [Range(18, 120, ErrorMessage = "Age must be between 18 and 120")]
    public int Age { get; set; }
    
    [DateField("Date of Birth")]
    public DateTime BirthDate { get; set; }
    
    [SelectField("Country", "United States", "Canada", "United Kingdom", "Australia")]
    public string Country { get; set; } = string.Empty;
    
    [TextArea("Bio", "Tell us about yourself")]
    [MaxLength(500)]
    public string Bio { get; set; } = string.Empty;
    
    [CheckboxField("Newsletter", "Subscribe to our newsletter")]
    public bool SubscribeToNewsletter { get; set; }
}

Generate the Form with One Line

var formConfig = FormBuilder<UserRegistration>.Create()
    .AddFieldsFromAttributes()  // That's it! 🎉
    .Build();

Available Attribute Types

  • [TextField] - Standard text input
  • [EmailField] - Email input with validation
  • [NumberField] - Numeric input with min/max support
  • [DateField] - Date picker with constraints
  • [SelectField] - Dropdown with predefined options
  • [CheckboxField] - Boolean checkbox
  • [TextArea] - Multiline text input

All attributes work seamlessly with standard DataAnnotations validators like [Required], [MinLength], [MaxLength], [Range], and more!

Comparison: Fluent API vs Attributes

Fluent API Attribute-Based
var config = FormBuilder<User>.Create()
    .AddField(x => x.Name, field => field
        .WithLabel("Full Name")
        .WithPlaceholder("Enter name")
        .Required("Name is required")
        .WithMinLength(2))
    .AddField(x => x.Email, field => field
        .WithLabel("Email")
        .WithInputType("email")
        .Required())
    .Build();
public class User
{
    [TextField("Full Name", "Enter name")]
    [Required(ErrorMessage = "Name is required")]
    [MinLength(2)]
    public string Name { get; set; }
    
    [EmailField("Email")]
    [Required]
    public string Email { get; set; }
}

// One line to generate!
var config = FormBuilder<User>.Create()
    .AddFieldsFromAttributes()
    .Build();

🎨 Examples

Dynamic Field Dependencies

Create forms where fields react to each other. DependsOn(watchedField, callback) runs the callback whenever the watched field changes, letting you reset or recalculate dependent values:

var formConfig = FormBuilder<OrderForm>.Create()
    .AddDropdownField(x => x.ProductType, "Product Type",
        ("standard", "Standard"),
        ("premium", "Premium"))
    .AddField(x => x.ProductModel, field => field
        .WithLabel("Model")
        .WithOptions(
            ("basic", "Basic Model"),
            ("pro", "Pro Model"))
        // Reset the model whenever Product Type changes
        .DependsOn(x => x.ProductType, (model, productType) =>
            model.ProductModel = string.Empty))
    .AddNumericField(x => x.Quantity, "Quantity", min: 1)
    .AddField(x => x.TotalPrice, field => field
        .WithLabel("Total Price")
        .ReadOnly()
        // Recalculate the total whenever Quantity changes
        .DependsOn(x => x.Quantity, (model, quantity) =>
            model.TotalPrice = quantity * GetUnitPrice(model.ProductModel)))
    .Build();

Custom Validation

Add complex validation logic with ease:

.AddField(x => x.Username, field => field
    .WithValidator(
        username => !forbiddenUsernames.Contains(username.ToLower()),
        "This username is not available")
    .WithAsyncValidator(
        async username => await UserService.IsUsernameAvailableAsync(username),
        "Username is already taken"))

If a validator needs access to other model values or DI services, implement IFieldValidator<TModel, TValue> — its ValidateAsync(model, value, services) method receives the full model and the IServiceProvider:

public class UniqueUsernameValidator : IFieldValidator<User, string>
{
    public string? ErrorMessage { get; set; } = "Username is already taken";

    public async Task<ValidationResult> ValidateAsync(
        User model, string value, IServiceProvider services)
    {
        var userService = services.GetRequiredService<IUserService>();
        return await userService.IsUsernameAvailableAsync(value)
            ? ValidationResult.Success()
            : ValidationResult.Failure("Username is already taken");
    }
}

// Usage
.AddField(x => x.Username, field => field
    .WithValidator(new UniqueUsernameValidator()))

Multiple Layouts

Choose the layout that fits your design:

// Vertical Layout (default)
.WithLayout(FormLayout.Vertical)

// Horizontal Layout
.WithLayout(FormLayout.Horizontal)

// Grid Layout
.WithLayout(FormLayout.Grid)

// Inline Layout
.WithLayout(FormLayout.Inline)

Column counts are configured per field group rather than at the form level:

.AddFieldGroup(group => group
    .WithGroupName("Address")
    .WithColumns(2)  // Two-column layout for this group
    .AddField(x => x.City)
    .AddField(x => x.PostalCode))

Advanced Field Types

// Password field with strength requirements
.AddPasswordField(x => x.Password, "Password", minLength: 8, requireSpecialChars: true)

// Password confirmation via a model-aware validator
.AddField(x => x.ConfirmPassword, field => field
    .WithLabel("Confirm Password")
    .WithInputType("password")
    .Required("Please confirm your password")
    .WithValidator(new PasswordsMatchValidator()))

// Date picker with validation (DateTime properties render as date pickers automatically)
.AddField(x => x.BirthDate, field => field
    .WithLabel("Date of Birth")
    .WithValidator(date => date <= DateTime.Today.AddYears(-18), "Must be 18 or older")
    .WithHelpText("Must be 18 or older"))

// Multi-line text with character limit
.AddField(x => x.Description, field => field
    .WithLabel("Description")
    .AsTextArea(lines: 5, maxLength: 500)
    .WithMaxLength(500, "Maximum 500 characters")
    .WithHelpText("Maximum 500 characters"))

// File upload
.AddFileUploadField(x => x.Resume, "Upload Resume",
    acceptedFileTypes: new[] { ".pdf", ".doc", ".docx" },
    maxFileSize: 5 * 1024 * 1024) // 5MB
    
// Multiple file upload
.AddMultipleFileUploadField(x => x.Documents, "Upload Documents",
    maxFiles: 3,
    acceptedFileTypes: new[] { ".pdf", ".jpg", ".png" },
    maxFileSize: 10 * 1024 * 1024) // 10MB per file

The password confirmation validator compares against the rest of the model:

public class PasswordsMatchValidator : IFieldValidator<RegistrationModel, string>
{
    public string? ErrorMessage { get; set; } = "Passwords do not match";

    public Task<ValidationResult> ValidateAsync(
        RegistrationModel model, string value, IServiceProvider services)
        => Task.FromResult(value == model.Password
            ? ValidationResult.Success()
            : ValidationResult.Failure("Passwords do not match"));
}

🛠️ Advanced Features

Conditional Fields

Show/hide or disable fields based on conditions:

.AddField(x => x.CompanyName, field => field
    .WithLabel("Company Name")
    .VisibleWhen(model => model.UserType == UserType.Business))

.AddField(x => x.TaxId, field => field
    .WithLabel("Tax ID")
    .VisibleWhen(model => model.Country == "US")
    .DisabledWhen(model => model.IsLocked))

For conditional requiredness, use a model-aware validator that only fails when the condition applies:

.AddField(x => x.TaxId, field => field
    .WithLabel("Tax ID")
    .WithValidator(new RequiredWhenUsValidator()))

public class RequiredWhenUsValidator : IFieldValidator<BusinessModel, string>
{
    public string? ErrorMessage { get; set; } = "Tax ID is required for US companies";

    public Task<ValidationResult> ValidateAsync(
        BusinessModel model, string value, IServiceProvider services)
        => Task.FromResult(model.Country == "US" && string.IsNullOrWhiteSpace(value)
            ? ValidationResult.Failure("Tax ID is required for US companies")
            : ValidationResult.Success());
}

Field Groups

Organize related fields into groups with customizable layouts:

var formConfig = FormBuilder<UserModel>
    .Create()
    .AddFieldGroup(group => group
        .WithGroupName("Personal Information")
        .WithColumns(2)  // Two-column layout
        .ShowInCard(2)   // Show in card with elevation 2
        .AddField(x => x.FirstName, field => field
            .WithLabel("First Name")
            .Required())
        .AddField(x => x.LastName, field => field
            .WithLabel("Last Name")
            .Required())
        .AddField(x => x.DateOfBirth))
    .AddFieldGroup(group => group
        .WithGroupName("Contact Information")
        .WithColumns(3)  // Three-column layout
        .ShowInCard()    // Default elevation 1
        .AddField(x => x.Email)
        .AddField(x => x.Phone)
        .AddField(x => x.Address))
    .Build();

Security Features (v2.0.0+)

Configure security settings for your forms:

var formConfig = FormBuilder<SecureForm>.Create()
    .AddField(x => x.SSN, field => field
        .WithLabel("Social Security Number")
        .WithPlaceholder("XXX-XX-XXXX"))
    .AddField(x => x.CreditCard, field => field
        .WithLabel("Credit Card")
        .WithPlaceholder("XXXX XXXX XXXX XXXX"))
    .WithSecurity(security => security
        .EncryptField(x => x.SSN)           // Mark sensitive fields for encryption
        .EncryptField(x => x.CreditCard)
        .EnableCsrfProtection()             // Configure anti-forgery tokens
        .WithRateLimit(5, TimeSpan.FromMinutes(1))  // Max 5 submissions per minute
        .EnableAuditLogging())              // Configure audit logging
    .Build();

How enforcement works (v3.1+): WithSecurity() stores the security settings on the form configuration, AddFormCraft() registers the supporting services (IEncryptionService, ICsrfTokenService, IRateLimitService, IAuditLogService), and FormCraftComponent enforces them automatically: a CSRF token is generated on initialization and validated before OnValidSubmit fires, rate limits are checked (and attempts recorded) before validation, and audit entries (FormSubmitted / FormRejected) are written with excluded and encrypted fields redacted. Blocked submissions show an error alert above the submit button and never reach your handler. Set the SecurityContextId parameter to a per-user value (user id, session id, IP) so rate limits aren't shared across users; it defaults to the model type name. Encryption remains an application concern: call encryptionService.EncryptConfiguredFields(model, config.Security) (or the component's GetEncryptedFieldValues()) to obtain the encrypted values of the marked fields in one call before persisting. Since v3.0.0 the default registration is AES-256 (DefaultEncryptionService) with a random IV per operation — configure a 32-byte key for values that must survive a process restart (an ephemeral per-process key is generated otherwise). On WebAssembly a browser-compatible fallback (BlazorEncryptionService, XOR-based obfuscation) is registered instead — treat it as obfuscation, not encryption. See the security documentation for details.

Custom Field Renderers

Create specialized input controls for specific field types:

// Create a custom renderer
public class ColorPickerRenderer : CustomFieldRendererBase<string>
{
    public override RenderFragment Render(IFieldRenderContext context)
    {
        return builder =>
        {
            var value = GetValue(context) ?? "#000000";
            
            builder.OpenElement(0, "input");
            builder.AddAttribute(1, "type", "color");
            builder.AddAttribute(2, "value", value);
            builder.AddAttribute(3, "onchange", EventCallback.Factory.CreateBinder<string>(
                this, async (newValue) => await SetValue(context, newValue), value));
            builder.CloseElement();
        };
    }
}

// Use in your form configuration (type arguments: model, value, renderer)
.AddField(x => x.Color, field => field
    .WithLabel("Product Color")
    .WithCustomRenderer<ProductModel, string, ColorPickerRenderer>()
    .WithHelpText("Select the primary color"))

// Register custom renderers (optional for DI)
services.AddScoped<ColorPickerRenderer>();
services.AddScoped<RatingRenderer>();

Built-in example renderers:

  • ColorPickerRenderer - Visual color selection with hex input
  • RatingRenderer - Star-based rating control using MudBlazor

📊 Performance

FormCraft is designed for optimal performance:

  • ⚡ Minimal re-renders using field-level change detection
  • 🎯 Targeted validation execution
  • 🔄 Efficient dependency tracking
  • 📦 Small bundle size (~50KB gzipped)

🧪 Testing

FormCraft is extensively tested with over 880 unit tests covering:

  • ✅ All field types and renderers
  • ✅ Validation scenarios
  • ✅ Field dependencies
  • ✅ Edge cases and error handling
  • ✅ Integration scenarios

🤝 Contributing

We love contributions! Please see our Contributing Guide for details.

Quick Start for Contributors

# Clone the repository
git clone https://github.com/phmatray/FormCraft.git

# Build the project
dotnet build

# Run tests
dotnet test

# Create a local NuGet package
./pack-local.sh  # or pack-local.ps1 on Windows

📖 Documentation

📚 Complete Documentation - Interactive docs with live examples

🗺️ Roadmap

✅ Completed

  • File upload field type
  • Security features (encryption, CSRF, rate limiting, audit logging)
  • Modular UI framework architecture
  • Wizard/stepper forms
  • Form templates library (FormTemplates)
  • DateOnly/TimeOnly field support
  • List-of-Values (LOV) modal selection fields
  • Automatic CSRF/rate-limit enforcement in FormCraftComponent (v3.1)
  • Zero-config form generation — AddFieldsAuto() (v3.1)
  • Async field dependencies and nullable value-type round-trip (v3.1)

🚧 In Progress

  • Import/Export forms as JSON
  • Rich text editor field

📋 Planned

  • Drag-and-drop form builder UI
  • Localization support
  • More layout options
  • Integration with popular CSS frameworks
  • Form state persistence

💬 Community

📄 License

FormCraft is licensed under the MIT License.

🙏 Acknowledgments

  • MudBlazor for the amazing component library
  • FluentValidation for validation inspiration
  • The Blazor community for feedback and support

If you find FormCraft useful, please consider giving it a ⭐ on GitHub!

Made with ❤️ by phmatray