6

I want to write a custom drop down list component for Blazor partly due to the fact that the existing InputSelect component does not bind to anything other than string and enum types. This is not good enough for me since my models have int and nullable int type properties that I want bound to the drop down list. So far I have this:

@using System.Globalization

@typeparam TValue
@typeparam TData

@inherits InputBase<TValue>

<select id="@Id" @bind="CurrentValueAsString" class="f-select js-form-field">
    @if (!string.IsNullOrWhiteSpace(OptionLabel) || Value == null)
    {
        <option value="">@(OptionLabel ?? "-- SELECT --")</option>
    }
    @foreach (var item in Data)
    {
        <option value="@GetPropertyValue(item, ValueFieldName)">@GetPropertyValue(item, TextFieldName)</option>
    }
</select>
<span>Component Value is: @Value</span>

@code {

    [Parameter]
    public string Id { get; set; }

    [Parameter]
    public IEnumerable<TData> Data { get; set; } = new List<TData>();

    [Parameter]
    public string ValueFieldName { get; set; }

    [Parameter]
    public string TextFieldName { get; set; }

    [Parameter]
    public string OptionLabel { get; set; }

    private Type ValueType => IsValueTypeNullable() ? Nullable.GetUnderlyingType(typeof(TValue)) : typeof(TValue);

    protected override void OnInitialized()
    {
        base.OnInitialized();
        ValidateInitialization();
    }

    private void ValidateInitialization()
    {
        if (string.IsNullOrWhiteSpace(ValueFieldName))
        {
            throw new ArgumentNullException(nameof(ValueFieldName), $"Parameter {nameof(ValueFieldName)} is required.");
        }
        if (string.IsNullOrWhiteSpace(TextFieldName))
        {
            throw new ArgumentNullException(nameof(TextFieldName), $"Parameter {nameof(TextFieldName)} is required.");
        }
        if (!HasProperty(ValueFieldName))
        {
            throw new Exception($"Data type {typeof(TData)} does not have a property called {ValueFieldName}.");
        }
        if (!HasProperty(TextFieldName))
        {
            throw new Exception($"Data type {typeof(TData)} does not have a property called {TextFieldName}.");
        }
    }

    protected override bool TryParseValueFromString(string value, out TValue result, out string validationErrorMessage)
    {
        validationErrorMessage = null;
        if (ValueType == typeof(string))
        {
            result = (TValue)(object)value;
            return true;
        }
        if (ValueType == typeof(int))
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                result = default;
            }
            else
            {
                if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue))
                {
                    result = (TValue)(object)parsedValue;
                }
                else
                {
                    result = default;
                    validationErrorMessage = $"Specified value cannot be converted to type {typeof(TValue)}";
                    return false;
                }
            }
            return true;
        }
        if (ValueType == typeof(Guid))
        {
            validationErrorMessage = null;
            if (string.IsNullOrWhiteSpace(value))
            {
                result = default;
            }
            else
            {
                if (Guid.TryParse(value, out var parsedValue))
                {
                    result = (TValue)(object)parsedValue;
                }
                else
                {
                    result = default;
                    validationErrorMessage = $"Specified value cannot be converted to type {typeof(TValue)}";
                    return false;
                }
            }
            return true;
        }

        throw new InvalidOperationException($"{GetType()} does not support the type '{typeof(TValue)}'. Supported types are string, int and Guid.");
    }

    private string GetPropertyValue(TData source, string propertyName)
    {
        return source.GetType().GetProperty(propertyName)?.GetValue(source, null).ToString();
    }

    private bool HasProperty(string propertyName)
    {
        return typeof(TData).GetProperty(propertyName) != null;
    }

    private bool IsValueTypeNullable()
    {
        return Nullable.GetUnderlyingType(typeof(TValue)) != null;
    }

}

And in the parent component I can use it like this:

<DropDownList Id="@nameof(Model.SelectedYear)"
    @bind-Value="Model.SelectedYear"
    Data="Model.Years"
    ValueFieldName="@nameof(Year.Id)"
    TextFieldName="@nameof(Year.YearName)">
</DropDownList>

This works very well, the model binds to the drop down list and the value is changed on the parent model when the drop down list value is changed. However I now want to capture this value change event on my parent and do some custom logic, mainly load some additional data based on the selected year. My guess is that I need a custom EventCallback but everything I tried causes some sort of build or runtime error. It seems that if my component inherits from InputBase then I am very much limited in what I can do.

Can anyone tell me how can I capture the value change from a child component in a parent component?

4
  • Why not derive from InputText ?
    – enet
    Commented Jun 11, 2020 at 17:48
  • @enet I need to render HTML select tag not input type text. I actually tried to inherit from InputSelect unsuccessfully because it only handles string and enum binding - fails when you try to bind to a property of any other type.
    – Marko
    Commented Jun 11, 2020 at 23:11
  • Is your code working ? See my answer sample... This is how you should do it with other types if needed.
    – enet
    Commented Jun 11, 2020 at 23:54
  • @enet Yeah my code is working. I did not try yours but it seems resonable. I supose I could've done it like that but I don't like the idea of having to generate option tags myself via some sort of loop. I wanted the component to do that for me too.
    – Marko
    Commented Jun 12, 2020 at 0:13

1 Answer 1

6

My guess is that I need a custom EventCallback

You sure need a EventCallback, but the thing is, you already have one, just don't see it.

To be able to use @bind-Value you need two parameters, T Value and EventCallback<T> ValueChanged.

When you pass @bind-Foo, blazor sets these two parameters, Foo and FooChanged and in the FooChanged it will simply set the new value to Foo.

So when you do @bind-Foo="Bar" what blazor does under the hood is pass these two parameters

Foo="@Bar"
FooChanged="@(newValue => Bar = newValue)"

So in your case, what you need to do is pass your own ValueChanged function, that sets the new value in Value but also do some extra things you want.

<DropDownList Id="@nameof(Model.SelectedYear)"
    Value="Model.SelectedYear"
    ValueChanged="@((TYPE_OF_VALUE newValue) => HandleValueChanged(newValue))"
    Data="Model.Years"
    ValueFieldName="@nameof(Year.Id)"
    TextFieldName="@nameof(Year.YearName)">
</DropDownList>

@code 
{
    void HandleValueChanged(TYPE_OF_VALUE newValue)
    {
        // do what you want to do 

        // set the newValue if you want
        Model.SelectedYear = newValue;
    }
}

In TYPE_OF_VALUE, you just replace it with the type of Model.SelectedYear.

You can take a look at this explanation in the docs.

Edit

Because you want to use nullable types, you also need to pass FooExpression which in your case will be Expression<Func<T>> ValueExpression.

<DropDownList Id="@nameof(Model.SelectedYear)"
    Value="Model.SelectedYear"
    ValueChanged="@((TYPE_OF_VALUE newValue) => HandleValueChanged(newValue))"
    ValueExpression="@(() => Model.SelectedYear)"
    Data="Model.Years"
    ValueFieldName="@nameof(Year.Id)"
    TextFieldName="@nameof(Year.YearName)">
</DropDownList>
6
  • Yeah I thought that too, but when I add ValueChanged="MyChangeHandler" and then create that handler with a single parameter of type nullable int I get the following compile error: "Argument 2: Cannot convert from method group to EventCallback". After more research found that I need to do this: ValueChanged="@((int? param) => HandleValueChanged(param))." Can you update your answer and I'll accept it as correct?
    – Marko
    Commented Jun 11, 2020 at 12:40
  • You get "Argument 2: Cannot convert from method group to EventCallback", but how you defined MyChangeHandler? And there is a difference between ValueChanged="MyChangeHandler" without @ and ValueChanged="@MyChangeHandler" with @. Please show me how you defined MyChangeHandler
    – Vencovsky
    Commented Jun 11, 2020 at 12:44
  • Whether I include @ or not makes no difference the error is the same. The handler is defined as: private void HandleValueChanged(int? newValue) { Model.SelectedClassYear = newValue; }
    – Marko
    Commented Jun 11, 2020 at 12:45
  • Based on github.com/dotnet/aspnetcore/issues/12226 it's a known issue I guess.
    – Marko
    Commented Jun 11, 2020 at 12:48
  • 1
    Yup you are correct I do need the ValueExpression directive. It works now I can confirm it.
    – Marko
    Commented Jun 11, 2020 at 12:58

Not the answer you're looking for? Browse other questions tagged or ask your own question.