Skip to content

Commit a923fa2

Browse files
linkdotnetegil
andauthored
feat: Add bind function to simulate @Bind directive (#750)
* feat: Add bind function to simulate @Bind directive * add: Documentation and samples * added null checks and tests * Apply suggestions from code review Co-authored-by: Egil Hansen <egil@assimilated.dk> * Better explanation if Bind encounters an error * Added Tests * tweaks to error messages * Only replace last instance instead of every * Apply suggestions from code review Co-authored-by: Egil Hansen <egil@assimilated.dk> * added e2e test * wip - compare bind with Bind * Added small example for bind in CHANGELOG.md * improved bind compare test * Update CHANGELOG.md * fix changelog Co-authored-by: Egil Hansen <egil@assimilated.dk>
1 parent ed78280 commit a923fa2

14 files changed

Lines changed: 376 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,34 @@ All notable changes to **bUnit** will be documented in this file. The project ad
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- Added `Bind` method to parameter builder that makes it easier to emulate the `@bind-Value` syntax in C#-based tests.
12+
13+
When writing tests in razor files, the `@bind-` directive can be directly applied like this:
14+
15+
```razor
16+
<MyComponent @bind-Value="myParam"></MyComponent>
17+
```
18+
19+
The same expression in C# syntax is more verbose like this:
20+
21+
```csharp
22+
RenderComponent<MyComponent>(ps => ps
23+
.Add(c => c.Value, value)
24+
.Add(c => c.ValueChanged, newValue => value = newValue)
25+
.Add(c => c.ValueExpression, () => value));
26+
```
27+
28+
With the new `Bind` method this can be done in one method:
29+
30+
```csharp
31+
RenderComponent<MyComponent>(ps => ps
32+
.Bind(c => c.Value, value, newValue => value = newValue, () => value));
33+
```
34+
35+
By [@linkdotnet](https://github.com/linkdotnet) and [@egil](https://github.com/egil).
36+
937
## [1.9.8] - 2022-06-07
1038

1139
### Changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@code {
2+
[Parameter] public string Value { get; set; } = string.Empty;
3+
[Parameter] public EventCallback<string> ValueChanged { get; set; }
4+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
@code {
2+
[Fact]
3+
public void Test()
4+
{
5+
using var ctx = new TestContext();
6+
var currentValue = string.Empty;
7+
8+
var cut = ctx.Render(@<TwoWayBinding @bind-Value="currentValue"></TwoWayBinding>);
9+
}
10+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using Xunit;
2+
3+
namespace Bunit.Docs.Samples;
4+
5+
public class TwoWayBindingTest
6+
{
7+
[Fact]
8+
public void Test()
9+
{
10+
using var ctx = new TestContext();
11+
var currentValue = string.Empty;
12+
13+
ctx.RenderComponent<TwoWayBinding>(parameters =>
14+
parameters.Bind(
15+
p => p.Value,
16+
currentValue,
17+
newValue => currentValue = newValue));
18+
}
19+
}

docs/site/docs/providing-input/passing-parameters-to-components.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,18 +375,37 @@ It is possible to nest a component under tests inside other components, if that
375375

376376
# [C# test code](#tab/csharp)
377377

378-
[!code-csharp[](../../../samples/tests/xunit/NestedComponentTest.cs#L11-L23)]
378+
[!code-csharp[NestedComponentTest](../../../samples/tests/xunit/NestedComponentTest.cs#L11-L23)]
379379

380380
The example renders the `<HelloWorld>` component inside the `<Wrapper>` component. What is special in both cases is the use of the `FindComponent<HelloWorld>()` that returns a `IRenderedComponent<HelloWorld>`. This is needed because the `RenderComponent<Wrapper>` method call returns an `IRenderedComponent<Wrapper>` instance, that provides access to the instance of the `<Wrapper>` component, but not the `<HelloWorld>`-component instance.
381381

382382
# [Razor test code](#tab/razor)
383383

384-
[!code-cshtml[](../../../samples/tests/razor/NestedComponentTest.razor)]
384+
[!code-cshtml[NestedComponentTest](../../../samples/tests/razor/NestedComponentTest.razor)]
385385

386386
The example passes a inline Razor template to the `Render<TComponent>()` method. What is different here from the previous examples is that we use the generic version of the `Render<TComponent>` method, which is a shorthand for `Render(...).FindComponent<TComponent>()`.
387387

388388
***
389389

390+
## Configure two-way with component parameters (`@bind` directive)
391+
392+
To set up [two-way binding to a pair of component parameters](https://docs.microsoft.com/en-us/aspnet/core/blazor/components/data-binding#binding-with-component-parameters) on a component under test, e.g. the `Value` and `ValueChanged` parameter pair on the component below, do the following:
393+
394+
[!code-csharp[TwoWayBinding.razor](../../../samples/components/TwoWayBinding.razor)]
395+
396+
# [C# test code](#tab/csharp)
397+
398+
[!code-csharp[TwoWayBindingTest.cs](../../../samples/tests/xunit/TwoWayBindingTest.cs#L5-L19)]
399+
400+
The example uses the `Bind` method to setup two-way binding between the `Value` parameter and `ValueChanged` parameter, and the local variable in the test method (`currentValue`). The `Bind` method is a shorthand for calling the the `Add` method for the `Value` parameter and `ValueChanged` parameter individually.
401+
402+
# [Razor test code](#tab/razor)
403+
404+
[!code-cshtml[TwoWayBindingTest.razor](../../../samples/tests/razor/TwoWayBindingTest.razor)]
405+
406+
The example uses the standard `@bind-Value` directive in Blazor to set up two way binding between the `Value` parameter and `ValueChanged` parameter and the local variable in the test method (`currentValue`).
407+
***
408+
390409
## Further Reading
391410

392411
- <xref:inject-services>

src/bunit.core/ComponentParameterCollectionBuilder.cs

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,79 @@ public ComponentParameterCollectionBuilder<TComponent> AddUnmatched(string name,
314314
return AddParameter(name, value);
315315
}
316316

317+
/// <summary>Adds two-way binding, simulating the <c>@bind-Parameter</c> directive, to a given pair of parameters.</summary>
318+
/// <param name="parameterSelector">Parameter-selector for the two-way binding.</param>
319+
/// <param name="initialValue">The initial value to pass to <typeparamref name="TComponent"/>.</param>
320+
/// <param name="changedAction">Action which gets invoked when the value has changed.</param>
321+
/// <param name="valueExpression">Optional value expression.</param>
322+
/// <returns>This <see cref="ComponentParameterCollectionBuilder{TComponent}"/>.</returns>
323+
/// <remarks>
324+
/// This function is a short-hand form for the following expression:
325+
/// <code>RenderComponent&lt;<typeparamref name="TComponent"/>&gt;(ps => ps
326+
/// .Add(c => c.Value, value)
327+
/// .Add(c => c.ValueChanged, newValue => value = newValue)
328+
/// .Add(c => c.ValueExpression, () => value));
329+
/// </code>
330+
/// With <c>Bind</c>, it can be written like this:
331+
/// <code>RenderComponent&lt;<typeparamref name="TComponent"/>&gt;(ps => ps
332+
/// .Bind(c => c.Value, value, newValue => value = newValue, () => value));
333+
/// </code>
334+
/// </remarks>
335+
public ComponentParameterCollectionBuilder<TComponent> Bind<TValue>(
336+
Expression<Func<TComponent, TValue>> parameterSelector,
337+
TValue initialValue,
338+
Action<TValue> changedAction,
339+
Expression<Func<TValue>>? valueExpression = null)
340+
{
341+
var (parameterName, _, isCascading) = GetParameterInfo(parameterSelector);
342+
343+
if (isCascading)
344+
throw new ArgumentException("Using Bind with a cascading parameter is not allowed.", parameterName);
345+
346+
if (changedAction is null)
347+
throw new ArgumentNullException(nameof(changedAction));
348+
349+
var changedName = $"{parameterName}Changed";
350+
var expressionName = $"{parameterName}Expression";
351+
352+
AssertBindTargetIsCorrect(parameterName, parameterSelector);
353+
354+
if (!HasPublicParameterProperty(changedName))
355+
throw new InvalidOperationException($"The parameter selector '{parameterSelector}' does not resolve to a " +
356+
$"parameter that has a related parameter with the name {changedName}. " +
357+
$"This is required for two way binding.");
358+
359+
AddParameter(parameterName, initialValue);
360+
AddParameter(changedName, EventCallback.Factory.Create(changedAction.Target!, changedAction));
361+
362+
return !HasPublicParameterProperty(expressionName)
363+
? this
364+
: AddParameter(expressionName, valueExpression ?? (() => initialValue));
365+
366+
static void AssertBindTargetIsCorrect(string parameterName, Expression<Func<TComponent, TValue>> parameterSelector)
367+
{
368+
var isBindEventParameter = parameterName.EndsWith("Changed", StringComparison.Ordinal) || parameterName.EndsWith("Expression", StringComparison.Ordinal);
369+
var isBindEventType = IsConcreteGenericOf(typeof(TValue), typeof(EventCallback<>)) || IsConcreteGenericOf(typeof(TValue), typeof(Expression<>));
370+
if (isBindEventParameter && isBindEventType)
371+
{
372+
var selectorExpression = parameterSelector.ToString();
373+
var possibleSelector = TrimEnd(parameterName, "Changed");
374+
possibleSelector = TrimEnd(possibleSelector, "Expression");
375+
throw new ArgumentException($"The parameter selector {selectorExpression} does not correspond " +
376+
$"to a valid target for a @bind expression.{Environment.NewLine}If the structure of the " +
377+
$"component is <MyComponent @bind-Value=\"value\" /> call " +
378+
$"Bind(p => p.Value, \"initial value\", p => p.ValueChanged, v => someVar = v);" +
379+
Environment.NewLine +
380+
$"Try {selectorExpression.Replace(parameterName, possibleSelector, StringComparison.Ordinal)} instead.");
381+
}
382+
}
383+
384+
static string TrimEnd(string source, string value)
385+
=> source.EndsWith(value, StringComparison.Ordinal)
386+
? source.Remove(source.LastIndexOf(value, StringComparison.Ordinal))
387+
: source;
388+
}
389+
317390
/// <summary>
318391
/// Try to add a <paramref name="value"/> for a parameter with the <paramref name="name"/>, if
319392
/// <typeparamref name="TComponent"/> has a property with that name, AND that property has a <see cref="ParameterAttribute"/>
@@ -401,4 +474,20 @@ private static RenderFragment GetRenderFragment<TChildComponent>(Action<Componen
401474
var childBuilder = new ComponentParameterCollectionBuilder<TChildComponent>(childParameterBuilder);
402475
return childBuilder.Build().ToRenderFragment<TChildComponent>();
403476
}
404-
}
477+
478+
private static bool HasPublicParameterProperty(string parameterName)
479+
{
480+
var type = typeof(TComponent);
481+
var property = type.GetProperty(parameterName);
482+
483+
return property != null && property.GetCustomAttributes(inherit: true).Any(a => a is ParameterAttribute);
484+
}
485+
486+
private static bool IsConcreteGenericOf(Type type, Type openGeneric)
487+
{
488+
if (!type.IsGenericType)
489+
return false;
490+
491+
return type.GetGenericTypeDefinition() == openGeneric;
492+
}
493+
}

0 commit comments

Comments
 (0)