Skip to content

Commit 5ce54ce

Browse files
committed
Merge branch 'main' into codex/add-test-for-custom-field-renderer-usage
2 parents 4ea1b0d + 77d3440 commit 5ce54ce

File tree

7 files changed

+262
-22
lines changed

7 files changed

+262
-22
lines changed

Directory.Packages.props

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@
66
<!-- Framework-agnostic packages -->
77
<ItemGroup>
88
<!-- UI Framework packages -->
9-
<PackageVersion Include="MudBlazor" Version="8.9.0" />
9+
<PackageVersion Include="MudBlazor" Version="8.11.0" />
1010

1111
<!-- Testing packages -->
1212
<PackageVersion Include="bunit" Version="1.40.0" />
1313
<PackageVersion Include="FakeItEasy" Version="8.3.0" />
1414
<PackageVersion Include="Shouldly" Version="4.3.0" />
15-
<PackageVersion Include="xunit" Version="2.9.3" />
1615
<PackageVersion Include="xunit.v3" Version="3.0.0" />
1716
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3">
1817
<PrivateAssets>all</PrivateAssets>
@@ -38,9 +37,9 @@
3837

3938
<!-- .NET 8.0 packages -->
4039
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
41-
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.18" />
42-
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.18" />
43-
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.18" />
40+
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.19" />
41+
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.19" />
42+
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.19" />
4443
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
4544
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
4645
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.1" />
@@ -49,12 +48,12 @@
4948

5049
<!-- .NET 9.0 packages -->
5150
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
52-
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="9.0.7" />
53-
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.7" />
54-
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.7" />
55-
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.7" />
56-
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.7" />
57-
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.7" />
58-
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.7" />
51+
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="9.0.8" />
52+
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.8" />
53+
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.8" />
54+
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" />
55+
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.8" />
56+
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.8" />
57+
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.8" />
5958
</ItemGroup>
6059
</Project>

FormCraft.UnitTests/Rendering/FieldRendererServiceTests.cs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,157 @@ public void RenderField_Should_Handle_Empty_Renderer_Collection()
276276
// Should return unsupported field type message
277277
}
278278

279+
[Fact]
280+
public void RenderField_Should_Detect_Correct_Type_For_Simple_MemberExpression()
281+
{
282+
// Arrange
283+
var model = new TestModel { Name = "Test" };
284+
var field = new FieldConfiguration<TestModel, string?>(x => x.Name);
285+
var wrapper = new FieldConfigurationWrapper<TestModel, string?>(field);
286+
Type? detectedType = null;
287+
288+
var mockRenderer = A.Fake<IFieldRenderer>();
289+
A.CallTo(() => mockRenderer.CanRender(A<Type>._, A<IFieldConfiguration<object, object>>._))
290+
.ReturnsLazily((Type type, IFieldConfiguration<object, object> _) =>
291+
{
292+
detectedType = type;
293+
return true;
294+
});
295+
A.CallTo(() => mockRenderer.Render(A<IFieldRenderContext<TestModel>>._))
296+
.Returns(builder => builder.AddContent(0, "Test"));
297+
298+
var service = new FieldRendererService(new[] { mockRenderer }, _serviceProvider);
299+
var onValueChanged = EventCallback.Factory.Create<object?>(this, _ => { });
300+
var onDependencyChanged = EventCallback.Factory.Create(this, () => { });
301+
302+
// Act
303+
service.RenderField(model, wrapper, onValueChanged, onDependencyChanged);
304+
305+
// Assert
306+
// The GetActualFieldType method in FieldRendererService handles UnaryExpression
307+
// and returns the underlying property type
308+
detectedType.ShouldBe(typeof(string));
309+
}
310+
311+
312+
[Fact]
313+
public void RenderField_Should_Detect_Correct_Type_For_Value_Type_MemberExpression()
314+
{
315+
// Arrange
316+
var model = new TestModel { Value = 42 };
317+
var field = new FieldConfiguration<TestModel, int>(x => x.Value);
318+
var wrapper = new FieldConfigurationWrapper<TestModel, int>(field);
319+
Type? detectedType = null;
320+
321+
var mockRenderer = A.Fake<IFieldRenderer>();
322+
A.CallTo(() => mockRenderer.CanRender(A<Type>._, A<IFieldConfiguration<object, object>>._))
323+
.ReturnsLazily((Type type, IFieldConfiguration<object, object> _) =>
324+
{
325+
detectedType = type;
326+
return true;
327+
});
328+
A.CallTo(() => mockRenderer.Render(A<IFieldRenderContext<TestModel>>._))
329+
.Returns(builder => builder.AddContent(0, "Test"));
330+
331+
var service = new FieldRendererService(new[] { mockRenderer }, _serviceProvider);
332+
var onValueChanged = EventCallback.Factory.Create<object?>(this, _ => { });
333+
var onDependencyChanged = EventCallback.Factory.Create(this, () => { });
334+
335+
// Act
336+
service.RenderField(model, wrapper, onValueChanged, onDependencyChanged);
337+
338+
// Assert
339+
// The GetActualFieldType method detects int from the UnaryExpression
340+
detectedType.ShouldBe(typeof(int));
341+
}
342+
343+
[Fact]
344+
public void RenderField_Should_Detect_Correct_Type_For_Nullable_Type()
345+
{
346+
// Arrange
347+
var model = new TestModel { NullableValue = 42 };
348+
var field = new FieldConfiguration<TestModel, int?>(x => x.NullableValue);
349+
var wrapper = new FieldConfigurationWrapper<TestModel, int?>(field);
350+
Type? detectedType = null;
351+
352+
var mockRenderer = A.Fake<IFieldRenderer>();
353+
A.CallTo(() => mockRenderer.CanRender(A<Type>._, A<IFieldConfiguration<object, object>>._))
354+
.ReturnsLazily((Type type, IFieldConfiguration<object, object> _) =>
355+
{
356+
detectedType = type;
357+
return true;
358+
});
359+
A.CallTo(() => mockRenderer.Render(A<IFieldRenderContext<TestModel>>._))
360+
.Returns(builder => builder.AddContent(0, "Test"));
361+
362+
var service = new FieldRendererService(new[] { mockRenderer }, _serviceProvider);
363+
var onValueChanged = EventCallback.Factory.Create<object?>(this, _ => { });
364+
var onDependencyChanged = EventCallback.Factory.Create(this, () => { });
365+
366+
// Act
367+
service.RenderField(model, wrapper, onValueChanged, onDependencyChanged);
368+
369+
// Assert
370+
detectedType.ShouldBe(typeof(int?));
371+
}
372+
373+
374+
[Fact]
375+
public void RenderField_Should_Use_Correct_ActualFieldType_In_Context()
376+
{
377+
// Arrange
378+
var model = new TestModel { DateCreated = DateTime.Now };
379+
var field = new FieldConfiguration<TestModel, DateTime>(x => x.DateCreated);
380+
var wrapper = new FieldConfigurationWrapper<TestModel, DateTime>(field);
381+
IFieldRenderContext<TestModel>? capturedContext = null;
382+
383+
var mockRenderer = A.Fake<IFieldRenderer>();
384+
A.CallTo(() => mockRenderer.CanRender(typeof(DateTime), A<IFieldConfiguration<object, object>>._))
385+
.Returns(true);
386+
A.CallTo(() => mockRenderer.Render(A<IFieldRenderContext<TestModel>>._))
387+
.ReturnsLazily((IFieldRenderContext<TestModel> ctx) =>
388+
{
389+
capturedContext = ctx;
390+
return builder => builder.AddContent(0, "Test");
391+
});
392+
393+
var service = new FieldRendererService(new[] { mockRenderer }, _serviceProvider);
394+
var onValueChanged = EventCallback.Factory.Create<object?>(this, _ => { });
395+
var onDependencyChanged = EventCallback.Factory.Create(this, () => { });
396+
397+
// Act
398+
service.RenderField(model, wrapper, onValueChanged, onDependencyChanged);
399+
400+
// Assert
401+
capturedContext.ShouldNotBeNull();
402+
capturedContext.ActualFieldType.ShouldBe(typeof(DateTime));
403+
}
404+
405+
406+
public enum TestEnum
407+
{
408+
Active,
409+
Inactive,
410+
Pending
411+
}
412+
413+
public class NestedModel
414+
{
415+
public string NestedProperty { get; set; } = string.Empty;
416+
}
417+
279418
public class TestModel
280419
{
281420
public string? Name { get; set; }
282421
public int Value { get; set; }
422+
public int? NullableValue { get; set; }
423+
public DateTime DateCreated { get; set; }
424+
public DateTime? DateModified { get; set; }
425+
public TestEnum Status { get; set; }
426+
public bool IsActive { get; set; }
427+
public decimal Price { get; set; }
428+
public List<string> Tags { get; set; } = new();
429+
public NestedModel NestedModel { get; set; } = new();
283430
}
284431

285432
private class FakeCustomRenderer : ICustomFieldRenderer<string>

FormCraft.UnitTests/Validators/RequiredValidatorTests.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ public class RequiredValidatorTests
55
private readonly RequiredValidator<TestModel, string> _stringValidator;
66
private readonly RequiredValidator<TestModel, int> _intValidator;
77
private readonly RequiredValidator<TestModel, int?> _nullableIntValidator;
8+
private readonly RequiredValidator<TestModel, bool> _boolValidator;
9+
private readonly RequiredValidator<TestModel, bool?> _nullableBoolValidator;
810
private readonly IServiceProvider _services;
911

1012
public RequiredValidatorTests()
1113
{
1214
_stringValidator = new RequiredValidator<TestModel, string>("Field is required");
1315
_intValidator = new RequiredValidator<TestModel, int>("Field is required");
1416
_nullableIntValidator = new RequiredValidator<TestModel, int?>("Field is required");
17+
_boolValidator = new RequiredValidator<TestModel, bool>("Field is required");
18+
_nullableBoolValidator = new RequiredValidator<TestModel, bool?>("Field is required");
1519
_services = A.Fake<IServiceProvider>();
1620
}
1721

@@ -99,6 +103,76 @@ public async Task ValidateAsync_Int_Should_Pass_When_NonZero()
99103
result.ErrorMessage.ShouldBeNull();
100104
}
101105

106+
[Fact]
107+
public async Task ValidateAsync_Bool_Should_Fail_When_False()
108+
{
109+
// Arrange
110+
var model = new TestModel();
111+
112+
// Act
113+
var result = await _boolValidator.ValidateAsync(model, false, _services);
114+
115+
// Assert
116+
result.IsValid.ShouldBeFalse();
117+
result.ErrorMessage.ShouldBe("Field is required");
118+
}
119+
120+
[Fact]
121+
public async Task ValidateAsync_Bool_Should_Pass_When_True()
122+
{
123+
// Arrange
124+
var model = new TestModel();
125+
126+
// Act
127+
var result = await _boolValidator.ValidateAsync(model, true, _services);
128+
129+
// Assert
130+
result.IsValid.ShouldBeTrue();
131+
result.ErrorMessage.ShouldBeNull();
132+
}
133+
134+
[Fact]
135+
public async Task ValidateAsync_NullableBool_Should_Fail_When_Null()
136+
{
137+
// Arrange
138+
var model = new TestModel();
139+
140+
// Act
141+
var result = await _nullableBoolValidator.ValidateAsync(model, null, _services);
142+
143+
// Assert
144+
result.IsValid.ShouldBeFalse();
145+
result.ErrorMessage.ShouldBe("Field is required");
146+
}
147+
148+
[Fact]
149+
public async Task ValidateAsync_NullableBool_Should_Fail_When_False()
150+
{
151+
// Arrange
152+
var model = new TestModel();
153+
154+
// Act
155+
var result = await _nullableBoolValidator.ValidateAsync(model, false, _services);
156+
157+
// Assert
158+
result.IsValid.ShouldBeFalse();
159+
result.ErrorMessage.ShouldBe("Field is required");
160+
}
161+
162+
[Fact]
163+
public async Task ValidateAsync_NullableBool_Should_Pass_When_True()
164+
{
165+
// Arrange
166+
var model = new TestModel();
167+
168+
// Act
169+
var result = await _nullableBoolValidator.ValidateAsync(model, true, _services);
170+
171+
// Assert
172+
result.IsValid.ShouldBeTrue();
173+
result.ErrorMessage.ShouldBeNull();
174+
}
175+
102176
[Fact]
103177
public async Task ValidateAsync_NullableInt_Should_Fail_When_Null()
104178
{

FormCraft/Forms/Extensions/ServiceCollectionExtensions.cs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,16 @@ public static IServiceCollection AddFormCraft(this IServiceCollection services)
3636

3737
// Only register built-in field renderers if no UI framework adapter is registered
3838
// This allows UI framework-specific renderers to take precedence
39-
services.AddScoped<IFieldRenderer, StringFieldRenderer>();
40-
services.AddScoped<IFieldRenderer, IntFieldRenderer>();
41-
services.AddScoped<IFieldRenderer, DecimalFieldRenderer>();
42-
services.AddScoped<IFieldRenderer, DoubleFieldRenderer>();
43-
services.AddScoped<IFieldRenderer, BoolFieldRenderer>();
44-
services.AddScoped<IFieldRenderer, DateTimeFieldRenderer>();
45-
services.AddScoped<IFieldRenderer, FileUploadFieldRenderer>();
39+
if (services.All(s => s.ServiceType != typeof(IUIFrameworkAdapter)))
40+
{
41+
services.AddScoped<IFieldRenderer, StringFieldRenderer>();
42+
services.AddScoped<IFieldRenderer, IntFieldRenderer>();
43+
services.AddScoped<IFieldRenderer, DecimalFieldRenderer>();
44+
services.AddScoped<IFieldRenderer, DoubleFieldRenderer>();
45+
services.AddScoped<IFieldRenderer, BoolFieldRenderer>();
46+
services.AddScoped<IFieldRenderer, DateTimeFieldRenderer>();
47+
services.AddScoped<IFieldRenderer, FileUploadFieldRenderer>();
48+
}
4649

4750
// Register security services
4851
services.AddScoped<IEncryptionService, BlazorEncryptionService>();

FormCraft/Forms/Rendering/FieldRendererService.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using Microsoft.AspNetCore.Components;
2+
using System.Linq.Expressions;
3+
using System.Reflection;
24

35
namespace FormCraft;
46

@@ -114,12 +116,26 @@ private static Type GetActualFieldType<TModel>(IFieldConfiguration<TModel, objec
114116
return property?.PropertyType ?? typeof(object);
115117
}
116118

117-
return field.ValueExpression.Body.Type;
119+
var expressionBody = field.ValueExpression.Body;
120+
121+
return expressionBody switch
122+
{
123+
MemberExpression
124+
{
125+
Member: PropertyInfo propertyInfo
126+
} => propertyInfo.PropertyType,
127+
UnaryExpression
128+
{
129+
NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked,
130+
Operand: MemberExpression { Member: PropertyInfo unaryPropertyInfo }
131+
} => unaryPropertyInfo.PropertyType,
132+
_ => expressionBody.Type
133+
};
118134
}
119135

120136
private static object GetCurrentValue<TModel>(TModel model, IFieldConfiguration<TModel, object> field)
121137
{
122138
var getter = field.ValueExpression.Compile();
123139
return getter(model);
124140
}
125-
}
141+
}

FormCraft/Forms/Validators/RequiredValidator.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public Task<ValidationResult> ValidateAsync(TModel model, TValue value, IService
4444
{
4545
null => false,
4646
string str => !string.IsNullOrWhiteSpace(str),
47+
bool b => b,
4748
System.Collections.IEnumerable enumerable => enumerable.Cast<object>().Any(),
4849
_ => true
4950
};

global.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"sdk": {
3-
"version": "9.0.302",
3+
"version": "9.0.304",
44
"rollForward": "latestMinor",
55
"allowPrerelease": false
66
}

0 commit comments

Comments
 (0)