Blazor two-way data binding with InputSelect never enters VM property set

44 views Asked by At

Inside a Blazor component, I'm trying to bind an instance of my viewmodel class to an InputSelect so I can select a value for an enum property. The VM also has a string property I can bind to a text input. The VM implements INotifyPropertyChanged, just like it did inside a WPF app, where the enum property was databound to a XAML ComboBox. A <p> element displays the selected enum property value, as you'd expect from two-way binding.

Whenever I change the string value in the input box, the string property's set gets called, per a breakpoint I've set there. But a similar breakpoint in the enum property's set never gets hit, even though the <p> it's bound to updates, and I'm trying to figure out what I'm missing. I know that if I use both @bind-Value and a change handler function, I get an error because then there are two change handlers, but how do I get the first change handler to do my bidding? Starting to miss WPF's automation mechanisms!

MyVM.cs:

namespace BlazorProj.Viewmodels {
  public enum Choice {
    Choice1,
    Choice2,
    Choice3,
    Choice4
  }

  public class ChoiceOption {
    public Choice Key { get; set; }
    public string OptionText { get; set; }
  }

  public class MyVM : INotifyPropertyChanged {
    public event PropertyChangedEventHandler? PropertyChanged;
    private string _strValue;
    private Choice? _selectedChoice;

    public string StrValue {
      get { return _strValue; }
      set {
        if (_strValue != value) {
          _strValue = value;
          NotifyListeners();
        }
      }
    }

    public Choice? SelectedChoice {
      get { return _selectedChoice; }
      set {
        if (_selectedChoice != value) {
          _selectedChoice = value;
          DoMoreStuff();
          NotifyListeners();
        }
      }
    }

    public ObservableCollection<ChoiceOption> ChoiceOptions { get; set; }   // Gets populated elsewhere

    private void DoMoreStuff() {
      // ...Stuff done when new choice selected
    }

    private void NotifyListeners([CallerMemberName] string propertyName = "") {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
  }
}

The component, Comp.razor:

@using BlazorProj.Viewmodels;

    @if (ViewModel == null) {
        <p><em>Loading...</em></p>
    } else {
        <InputSelect @bind-Value="@ViewModel.SelectedChoice">
            @if (ViewModel?.ChoiceOptions != null) {
                @foreach (var opt in ViewModel.ChoiceOptions) {
                    <option value="@opt.Key">@opt.OptionText</option>
                }
            } else {
                <option value="">No options</option>
            }
        </InputSelect>
        <p style="color:blue">@ViewModel.SelectedChoice.ToString()</p>
        <input @bind="@ViewModel.StrValue" />
    }

@code {
    MyVM ViewModel { get; set; }
}

CompPage.razor:

@page "/"
@using BlazorProj.Viewmodels;
@inject MyVM ViewModel

<Comp />

@code {
}

What must I do so DoMoreStuff gets called? Thanks...

EDIT: I've discovered that the set and DoMoreStuff DO get called if the InputSelect has focus and I hit the up or down arrow key, so I need to make it work for selecting from the drop-down list as well. Also, removed "Casading..." stuff since it wasn't needed, and corrected CompPage.razor to display a Comp instead of a Tests.

EDIT 2: I've now discovered that DoMoreStuff DOES get called and does stuff on selecting from the drop-down list, even if the breakpoint doesn't get hit. I guess this is a VS debugging issue?

1

There are 1 answers

6
MrC aka Shaun Curtis On

[Polite] I not sure you're going about this the right way. There should be no need for Cascading.

I've also removed the ObservableCollection which I think you're just using to detect changes in the model. I use an EventCallback instead which is called every time a value is edited and triggers a render in the parent.

Here's my alternative approach to what I think your trying to achieve [I may be wide of the mark!].

The editor:


<InputSelect class="form-select mb-3"
             TValue="int"
             @bind-Value:get="this.GetChoiceOption"
             @bind-Value:set="this.OnChoiceChanged">

    <option selected disabled value="-1"> -- Select an Option to Edit -- </option>
    
    @foreach (var choice in Choices)
    {
        <option value="@((int)choice.Key)">@choice.OptionText</option>
    }

</InputSelect>

<InputText class="form-control mb-3"
           hidden="@_noDelection"
           @bind-Value:get="GetOptionText"
           @bind-Value:set="this.OnChoiceTextChanged" />

@code {
    [Parameter] public List<ChoiceOption> Choices { get; set; } = new();
    [Parameter] public EventCallback ValuesChanged { get; set; }

    private ChoiceOption? _choiceOption;

    private int GetChoiceOption => _choiceOption is null ? -1 : (int)_choiceOption.Key;
    private string GetOptionText => _choiceOption is null || _choiceOption.OptionText is null ? string.Empty : _choiceOption.OptionText;
    private bool _noDelection => _choiceOption is null;

    private Task OnChoiceChanged(int value)
    {
        _choiceOption = Choices.FirstOrDefault(item => (int)item.Key == value);
        return Task.CompletedTask;
    }

    private async Task OnChoiceTextChanged(string value)
    {
        @if (_choiceOption is not null)
            _choiceOption.OptionText = value;

        await ValuesChanged.InvokeAsync();
    }
}

And the Demo page:

@page "/"

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<EnumEditor Choices="_choices" ValuesChanged="this.OnValuesChanged" />

<div class="bg-dark text-white">
    @foreach(var choice in _choices)
    {
        <pre>@choice.Key - @choice.OptionText</pre>
    }

</div>
@code{
    private List<ChoiceOption> _choices = new();

    protected override void OnInitialized()
    {
        foreach (Choice choice in Enum.GetValues<Choice>())
        {
            _choices.Add(new() { Key = choice, OptionText = Enum.GetName<Choice>(choice) });
        }
    }

    private Task OnValuesChanged()
    {
        return Task.CompletedTask;
    }
}