Skip to content

Commit 844e3ad

Browse files
authored
Merge pull request #43 from phmatray/codex/add-model-annotations-for-form-building
feat: build forms from model attributes
2 parents 07427a9 + 660b8f6 commit 844e3ad

27 files changed

+1802
-524
lines changed

.claude/settings.local.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(dotnet test:*)",
5+
"Bash(cat:*)",
6+
"Bash(dotnet run:*)"
7+
],
8+
"deny": []
9+
}
10+
}

CHANGELOG.md

Lines changed: 96 additions & 167 deletions
Large diffs are not rendered by default.

FormCraft.DemoBlazorApp/Components/Layout/MainLayout.razor

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
7272
<MudNavLink Href="fluent" Icon="@Icons.Material.Filled.FlashOn">
7373
Fluent API
7474
</MudNavLink>
75+
<MudNavLink Href="attribute-based-forms" Icon="@Icons.Material.Filled.Label">
76+
Attribute-Based Forms
77+
</MudNavLink>
7578
<MudNavLink Href="field-groups" Icon="@Icons.Material.Filled.GridView">
7679
Field Groups
7780
</MudNavLink>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
@page "/attribute-based-forms"
2+
@using FormCraft.DemoBlazorApp.Models
3+
4+
<DemoPageLayout
5+
Title="Attribute-Based Form Generation"
6+
Icon="@Icons.Material.Filled.Label"
7+
Description="Generate complete forms automatically from model attributes without writing any form configuration code. Simply decorate your model properties with field attributes."
8+
FormDemoIcon="@Icons.Material.Filled.AutoMode">
9+
<FormDemoContent>
10+
<FormDemoSection
11+
TModel="UserRegistrationModel"
12+
Model="@_model"
13+
Configuration="@_formConfiguration"
14+
FormTitle="User Registration Form"
15+
FormIcon="@Icons.Material.Filled.PersonAdd"
16+
IsSubmitted="@_isSubmitted"
17+
IsSubmitting="@_isSubmitting"
18+
OnValidSubmit="@HandleValidSubmit"
19+
OnReset="@ResetForm"
20+
DataDisplayItems="@GetDataDisplayItems()"
21+
ShowSubmitButton="true"
22+
SubmitButtonText="Register"
23+
SubmittingText="Registering..."
24+
SubmitButtonClass="px-8">
25+
<SidebarContent>
26+
<FormGuidelines Guidelines="@_sidebarFeatures" Title="Available Attributes" />
27+
</SidebarContent>
28+
<AdditionalContent>
29+
@if (_isSubmitted)
30+
{
31+
<MudAlert Severity="Severity.Success" Class="mt-4">
32+
Registration completed successfully! The form data has been processed.
33+
</MudAlert>
34+
}
35+
</AdditionalContent>
36+
</FormDemoSection>
37+
</FormDemoContent>
38+
39+
<CodeExampleContent>
40+
<CodeExample
41+
Title="Model with Attributes"
42+
Language="csharp"
43+
Code="@GetModelCode()" />
44+
45+
<CodeExample
46+
Title="Form Generation (Just One Line!)"
47+
Language="csharp"
48+
Code="@GetFormGenerationCode()"
49+
Class="mt-4" />
50+
</CodeExampleContent>
51+
52+
<GuidelinesContent>
53+
<ApiGuidelinesTable
54+
Title="Attribute-Based Form Guidelines"
55+
Items="@_apiGuidelineTableItems" />
56+
</GuidelinesContent>
57+
</DemoPageLayout>
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
using FormCraft.DemoBlazorApp.Components.Shared;
2+
using FormCraft.DemoBlazorApp.Models;
3+
using MudBlazor;
4+
using Microsoft.AspNetCore.Components;
5+
6+
namespace FormCraft.DemoBlazorApp.Components.Pages;
7+
8+
public partial class AttributeBasedForms : ComponentBase
9+
{
10+
private UserRegistrationModel _model = new();
11+
private IFormConfiguration<UserRegistrationModel>? _formConfiguration;
12+
private bool _isSubmitted = false;
13+
private bool _isSubmitting = false;
14+
15+
private readonly List<FormGuidelines.GuidelineItem> _sidebarFeatures =
16+
[
17+
new() { Icon = Icons.Material.Filled.TextFields, Text = "[TextField] - Text inputs" },
18+
new() { Icon = Icons.Material.Filled.Email, Text = "[EmailField] - Email validation" },
19+
new() { Icon = Icons.Material.Filled.Numbers, Text = "[NumberField] - Numeric inputs" },
20+
new() { Icon = Icons.Material.Filled.DateRange, Text = "[DateField] - Date pickers" },
21+
new() { Icon = Icons.Material.Filled.ArrowDropDown, Text = "[SelectField] - Dropdowns" },
22+
new() { Icon = Icons.Material.Filled.CheckBox, Text = "[CheckboxField] - Checkboxes" },
23+
new() { Icon = Icons.Material.Filled.Notes, Text = "[TextArea] - Multiline text" },
24+
new() { Icon = Icons.Material.Filled.Check, Text = "Automatic validation" },
25+
new() { Icon = Icons.Material.Filled.Speed, Text = "Zero configuration" }
26+
];
27+
28+
private readonly List<GuidelineItem> _apiGuidelineTableItems =
29+
[
30+
new()
31+
{
32+
Feature = "TextField Attribute",
33+
Usage = "Basic text input fields",
34+
Example = "[TextField(\"First Name\", \"Enter name\")]"
35+
},
36+
new()
37+
{
38+
Feature = "EmailField Attribute",
39+
Usage = "Email with format validation",
40+
Example = "[EmailField(\"Email Address\")]"
41+
},
42+
new()
43+
{
44+
Feature = "NumberField Attribute",
45+
Usage = "Numeric inputs with min/max",
46+
Example = "[NumberField(\"Age\")] [Range(18, 120)]"
47+
},
48+
new()
49+
{
50+
Feature = "DateField Attribute",
51+
Usage = "Date picker fields",
52+
Example = "[DateField(\"Birth Date\")]"
53+
},
54+
new()
55+
{
56+
Feature = "SelectField Attribute",
57+
Usage = "Dropdown with options",
58+
Example = "[SelectField(\"Country\", \"USA\", \"Canada\", ...)]"
59+
},
60+
new()
61+
{
62+
Feature = "CheckboxField Attribute",
63+
Usage = "Boolean checkbox fields",
64+
Example = "[CheckboxField(\"I agree\", \"Accept terms\")]"
65+
},
66+
new()
67+
{
68+
Feature = "TextArea Attribute",
69+
Usage = "Multiline text input",
70+
Example = "[TextArea(\"Comments\", \"Your feedback\")]"
71+
},
72+
new()
73+
{
74+
Feature = "Validation Attributes",
75+
Usage = "Standard DataAnnotations",
76+
Example = "[Required] [MinLength(2)] [MaxLength(50)]"
77+
}
78+
];
79+
80+
protected override void OnInitialized()
81+
{
82+
// Generate the entire form configuration from attributes with just one line!
83+
_formConfiguration = FormBuilder<UserRegistrationModel>
84+
.Create()
85+
.AddFieldsFromAttributes()
86+
.Build();
87+
}
88+
89+
private async Task HandleValidSubmit()
90+
{
91+
_isSubmitting = true;
92+
StateHasChanged();
93+
94+
// Simulate async operation
95+
await Task.Delay(1500);
96+
97+
_isSubmitting = false;
98+
_isSubmitted = true;
99+
StateHasChanged();
100+
101+
// Hide success message after 5 seconds
102+
await Task.Delay(5000);
103+
_isSubmitted = false;
104+
StateHasChanged();
105+
}
106+
107+
private void ResetForm()
108+
{
109+
_model = new UserRegistrationModel();
110+
_isSubmitted = false;
111+
_isSubmitting = false;
112+
StateHasChanged();
113+
}
114+
115+
private List<FormSuccessDisplay.DataDisplayItem> GetDataDisplayItems()
116+
{
117+
return
118+
[
119+
new() { Label = "Name", Value = $"{_model.FirstName} {_model.LastName}" },
120+
new() { Label = "Email", Value = _model.Email },
121+
new() { Label = "Age", Value = _model.Age.ToString() },
122+
new() { Label = "Birth Date", Value = _model.DateOfBirth.ToShortDateString() },
123+
new() { Label = "Country", Value = _model.Country },
124+
new() { Label = "Language", Value = _model.PreferredLanguage },
125+
new() { Label = "Experience", Value = $"{_model.YearsOfExperience} years" },
126+
new() { Label = "Salary", Value = _model.ExpectedSalary.ToString("C") },
127+
new() { Label = "Newsletter", Value = _model.SubscribeToNewsletter ? "Subscribed" : "Not Subscribed" },
128+
new() { Label = "Terms", Value = _model.AcceptTerms ? "Accepted" : "Not Accepted" },
129+
new() { Label = "Contact Date", Value = _model.PreferredContactDate?.ToShortDateString() ?? "Not specified" },
130+
new() { Label = "Bio Length", Value = $"{_model.Bio?.Length ?? 0} characters" },
131+
new() { Label = "Has Comments", Value = string.IsNullOrEmpty(_model.Comments) ? "No" : "Yes" }
132+
];
133+
}
134+
135+
private string GetModelCode()
136+
{
137+
return @"public class UserRegistrationModel
138+
{
139+
[TextField(""First Name"", ""Enter your first name"")]
140+
[Required(ErrorMessage = ""First name is required"")]
141+
[MinLength(2, ErrorMessage = ""First name must be at least 2 characters"")]
142+
[MaxLength(50, ErrorMessage = ""First name cannot exceed 50 characters"")]
143+
public string FirstName { get; set; } = string.Empty;
144+
145+
[TextField(""Last Name"", ""Enter your last name"")]
146+
[Required(ErrorMessage = ""Last name is required"")]
147+
[MinLength(2, ErrorMessage = ""Last name must be at least 2 characters"")]
148+
public string LastName { get; set; } = string.Empty;
149+
150+
[EmailField(""Email Address"")]
151+
[Required(ErrorMessage = ""Email is required"")]
152+
public string Email { get; set; } = string.Empty;
153+
154+
[NumberField(""Age"", ""Your age in years"")]
155+
[Required(ErrorMessage = ""Age is required"")]
156+
[Range(18, 120, ErrorMessage = ""Age must be between 18 and 120"")]
157+
public int Age { get; set; }
158+
159+
[DateField(""Date of Birth"")]
160+
[Required(ErrorMessage = ""Date of birth is required"")]
161+
public DateTime DateOfBirth { get; set; } = DateTime.Now.AddYears(-25);
162+
163+
[SelectField(""Country"", ""United States"", ""Canada"", ""United Kingdom"", ...)]
164+
[Required(ErrorMessage = ""Please select a country"")]
165+
public string Country { get; set; } = string.Empty;
166+
167+
[SelectField(""Preferred Language"", ""English"", ""Spanish"", ""French"", ...)]
168+
public string PreferredLanguage { get; set; } = ""English"";
169+
170+
[NumberField(""Years of Experience"")]
171+
[Range(0, 50, ErrorMessage = ""Years of experience must be between 0 and 50"")]
172+
public int YearsOfExperience { get; set; }
173+
174+
[TextArea(""Bio"", ""Tell us about yourself..."")]
175+
[MaxLength(500, ErrorMessage = ""Bio cannot exceed 500 characters"")]
176+
public string Bio { get; set; } = string.Empty;
177+
178+
[CheckboxField(""Subscribe to Newsletter"", ""I want to receive promotional emails"")]
179+
public bool SubscribeToNewsletter { get; set; }
180+
181+
[CheckboxField(""Accept Terms"", ""I accept the terms and conditions"")]
182+
[Required(ErrorMessage = ""You must accept the terms and conditions"")]
183+
public bool AcceptTerms { get; set; }
184+
185+
[DateField(""Preferred Contact Date"")]
186+
public DateTime? PreferredContactDate { get; set; }
187+
188+
[NumberField(""Expected Salary"", ""$0.00"")]
189+
[Range(0, 1000000, ErrorMessage = ""Salary must be between 0 and 1,000,000"")]
190+
public decimal ExpectedSalary { get; set; }
191+
192+
[TextArea(""Additional Comments"", ""Any additional information..."")]
193+
public string Comments { get; set; } = string.Empty;
194+
}";
195+
}
196+
197+
private string GetFormGenerationCode()
198+
{
199+
return @"// That's it! Just one line to generate the entire form from attributes:
200+
var formConfiguration = FormBuilder<UserRegistrationModel>
201+
.Create()
202+
.AddFieldsFromAttributes() // ← This reads all attributes and builds the form
203+
.Build();
204+
205+
// Then use it in your Blazor component:
206+
<FormCraftComponent TModel=""UserRegistrationModel""
207+
Model=""@_model""
208+
Configuration=""@_formConfiguration""
209+
OnValidSubmit=""HandleValidSubmit"" />";
210+
}
211+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using FormCraft;
3+
4+
namespace FormCraft.DemoBlazorApp.Models;
5+
6+
/// <summary>
7+
/// Demo model showcasing all available field attributes for form generation.
8+
/// </summary>
9+
public class UserRegistrationModel
10+
{
11+
[TextField("First Name", "Enter your first name")]
12+
[Required(ErrorMessage = "First name is required")]
13+
[MinLength(2, ErrorMessage = "First name must be at least 2 characters")]
14+
[MaxLength(50, ErrorMessage = "First name cannot exceed 50 characters")]
15+
public string FirstName { get; set; } = string.Empty;
16+
17+
[TextField("Last Name", "Enter your last name")]
18+
[Required(ErrorMessage = "Last name is required")]
19+
[MinLength(2, ErrorMessage = "Last name must be at least 2 characters")]
20+
[MaxLength(50, ErrorMessage = "Last name cannot exceed 50 characters")]
21+
public string LastName { get; set; } = string.Empty;
22+
23+
[EmailField("Email Address")]
24+
[Required(ErrorMessage = "Email is required")]
25+
public string Email { get; set; } = string.Empty;
26+
27+
[NumberField("Age", "Your age in years")]
28+
[Required(ErrorMessage = "Age is required")]
29+
[Range(18, 120, ErrorMessage = "Age must be between 18 and 120")]
30+
public int Age { get; set; }
31+
32+
[DateField("Date of Birth")]
33+
[Required(ErrorMessage = "Date of birth is required")]
34+
public DateTime DateOfBirth { get; set; } = DateTime.Now.AddYears(-25);
35+
36+
[SelectField("Country", "United States", "Canada", "United Kingdom", "Australia", "Germany", "France", "Japan", "Other")]
37+
[Required(ErrorMessage = "Please select a country")]
38+
public string Country { get; set; } = string.Empty;
39+
40+
[SelectField("Preferred Language", "English", "Spanish", "French", "German", "Chinese", "Japanese", "Other")]
41+
public string PreferredLanguage { get; set; } = "English";
42+
43+
[NumberField("Years of Experience")]
44+
[Range(0, 50, ErrorMessage = "Years of experience must be between 0 and 50")]
45+
public int YearsOfExperience { get; set; }
46+
47+
[TextArea("Bio", "Tell us about yourself...")]
48+
[MaxLength(500, ErrorMessage = "Bio cannot exceed 500 characters")]
49+
public string Bio { get; set; } = string.Empty;
50+
51+
[CheckboxField("Subscribe to Newsletter", "I want to receive promotional emails")]
52+
public bool SubscribeToNewsletter { get; set; }
53+
54+
[CheckboxField("Accept Terms", "I accept the terms and conditions")]
55+
[Required(ErrorMessage = "You must accept the terms and conditions")]
56+
public bool AcceptTerms { get; set; }
57+
58+
[DateField("Preferred Contact Date")]
59+
public DateTime? PreferredContactDate { get; set; }
60+
61+
[NumberField("Expected Salary", "$0.00")]
62+
[Range(0, 1000000, ErrorMessage = "Salary must be between 0 and 1,000,000")]
63+
public decimal ExpectedSalary { get; set; }
64+
65+
[TextArea("Additional Comments", "Any additional information...")]
66+
public string Comments { get; set; } = string.Empty;
67+
}

0 commit comments

Comments
 (0)