1

I have created a DropDownList box component which is bound to a long list of vendors.

ContractVendorSelect.razor

<div class="k-form-field-wrap">
    @if (IsLoaded)
    {
        <select @bind=Contract.Vendor>
            <option>-- Select</option>
            @foreach (var item in VendorData)
            {
                <option value="@item.Value">@item.Text</option>
            }
        </select>

    }
    else
    {
        <span>Loading vendors ... </span>
    }
</div>

I have placed my C# code in a code behind file.

ContractVendorSelect.razor.cs

public partial class ContractVendorSelect : FormFieldBase
{
    [Parameter]
    public Contract? Contract { get; set; }

    [Parameter]
    public string? Id { get; set; } = null;

    [Parameter]
    public string? Label { get; set; } = null;

    [Parameter]
    public Sites? Site { get; set; } = null;

    protected bool IsLoaded { get; set; } = false;

    internal List<VendorSelectData>? VendorData { get; set; } = new List<StiVendorSelectData>();
    
    private async Task SetVendorList()
    {
        if (Site != null)
        {
            var stagedData = await VendorSearch.GetVendorsAsync(this.Site.DBName);
    
            VendorData = new List<VendorSelectData>();
    
            foreach(Vendor vendor in stagedData)
            {
                VendorData.Add(new VendorSelectData(vendor.Vendor_Name, vendor.Vendor_Key, vendor));
            }
    
            IsLoaded = true;
        }
    }
    
    protected async override Task OnInitializedAsync()
    {
        await this.SetVendorList();
        StateHasChanged();
    }
    
    internal class VendorSelectData
    {
        public long Value { get; set; }
        public String Text { get; set; }
    
        public Vendor Vendor { get; set; }
    
        public VendorSelectData(string text, long value, Vendor model)
        {
            Text = text;
            Value = value;
            Vendor = model;
        }
    }
}

The problem I have is that the component does not re-render after the IsLoaded flag has been set to true (indicating that the list has been successfully retrieved and is ready to render). I have tried implementing "StateHasChanged" in a number of places but it continues to simply display the "Loadin vendors ... " message.

Any help or a point in the right direction would be greatly appreciated.

After reviewing this I failed to add the implementation of the component in a test page ...

Test.razor

<ContractVendorSelect Id="ddlContractVendor" 
        Label="Vendor" 
        Contract="contract" 
        Site="site">
</ContractVendorSelect>


@code {

    protected Contract contract { get; set; }

    protected Sites site { get; set; }


    public async Task SetContract()
    {
        contract = await ContractSearch.FindContract(<contractID>);
    }

    public async Task SetSite()
    {
        site = await SiteSearch.GetSiteByIDAsync(<SiteID>);
    }


    protected override async Task OnInitializedAsync()
    {
        await SetSite();
        await SetContract();
    }
}
Gary O. Stenstrom
  • 2,284
  • 9
  • 38
  • 59
  • The calls to `StateHasChanged()` shouldn't be necessary. Blazor will render after waiting for `OnInitializedAsync ` to complete - see [the Component Lifecycle](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/lifecycle?view=aspnetcore-7.0#lifecycle-events) for a helpful diagram. Have you put a breakpoint in the `SetVendorList` method and confirmed it's completing? – Andrew Williamson Apr 13 '23 at 21:48
  • I also notice you're binding the `select` value to `Contract.Vendor`. It's possible for the `Contract` to be null, which would result in a null-reference exception. You should check for this or add the `[EditorRequired]` attribute. I would also recommend initializing it to a new instance instead of null so the component can still render – Andrew Williamson Apr 13 '23 at 21:52
  • I think the issue could be that you are defining the parameters as nullable in the component but then you pass them as not nullable. Some sort of diffing / binding issue for sure. Did you place a breakpoint where `IsLoaded` is set to `true`? Is the breakpoint hit to verify if it is actually set to true? – ViRuSTriNiTy Apr 14 '23 at 09:26
  • Thank you everyone for you thoughts. I agree the `StateHasChanged()` call should not be necessary and in fact does not provide any help at all. Left it there mostly to illustrate that it had been tried. @ViRuSTriNiTy - The Contract is most definitely **not** null. I will look into the nullable issue, however I have set the breakpoint on the `IsLoaded` line to verify that it does get set to `true`. I can verify that the data **does** load and the flag is set to `true` ... just the drop down never re-renders itself. – Gary O. Stenstrom Apr 14 '23 at 14:08
  • Ideally, create a sample in Blazor repl. You will get instant answers! – Liero Apr 14 '23 at 20:50
  • Have you actually tried debugging and stepping thought the code? When ContractVendorSelect is being initialized, the Site property is still null. Debugger is your friend – Liero Apr 14 '23 at 20:52

4 Answers4

0

I can't see what the issue is in your code.

Based on the premise that writing a select for a specific field is rather time consuming, I suggest you consider the following.

First a generic select control that you can use with any list.

BlazrInputSelect.razor

@using System.Linq.Expressions;
@typeparam TValue
@typeparam TListItem

<InputSelect TValue="TValue" @attributes=AdditionalAttributes
             Value=this.Value
             ValueChanged="async (value) => await this.ValueChanged.InvokeAsync(value)"
             ValueExpression=this.ValueExpression>

    @if (this.Value is null)
    {
        <option selected disabled>@this.PlaceholderText</option>
    }

    @foreach (var option in this.DisplayOptionsItems)
    {
        <option value="@(this.OptionValueDelegate(option))">@(this.OptionTextDelegate(option))</option>
    }

</InputSelect>

@code {
    [Parameter] public TValue? Value { get; set; }
    [Parameter] public EventCallback<TValue?> ValueChanged { get; set; }
    [Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; }

    [Parameter, EditorRequired] public IEnumerable<TListItem> DisplayOptionsItems { get; set; } = default!;
    [Parameter, EditorRequired] public Func<TListItem, string> OptionValueDelegate { get; set; } = default!;
    [Parameter, EditorRequired] public Func<TListItem, string> OptionTextDelegate { get; set; } = default!;

    [Parameter] public string PlaceholderText { get; set; } = " -- Select an Option -- ";
    [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }

    protected override void OnInitialized()
    {
        // Check we have a Options list if not throw an exception before we try and render a null list
        ArgumentNullException.ThrowIfNull(this.DisplayOptionsItems);
        ArgumentNullException.ThrowIfNull(this.OptionValueDelegate);
        ArgumentNullException.ThrowIfNull(this.OptionTextDelegate);
    }
}

This is my quick data provider for your data with an async getter. It's registered in DI.

public class VendorDataProvider
{
    private List<Vendor> _vendorList;

    public VendorDataProvider()
    {
        _vendorList = new List<Vendor>()
        {
            new("France", Guid.NewGuid()),
            new("Spain", Guid.NewGuid()),
            new("Portugal", Guid.NewGuid()),
            new("UK", Guid.NewGuid()),
        };
    }

    public async ValueTask<IEnumerable<Vendor>> GetVendorsAsync()
    {
        // pretend to be a async data pipeline
        await Task.Delay(100);
        return _vendorList;
    }
}

public record Vendor(string Vendor_Name, Guid Vendor_Key);

And then a test page to demonstrate the control in action with your data.

@page "/"
@inject VendorDataProvider DataProvider

<PageTitle>Index</PageTitle>

<h1>Hello, world!</h1>
<BlazrInputSelect class="form-select"
                  DisplayOptionsItems=_vendorItems
                  OptionTextDelegate="(vendor) => vendor.Vendor_Name"
                  OptionValueDelegate="(vendor) => vendor.Vendor_Key.ToString()"
                  PlaceholderText=" Select a Vendor"
                  @bind-Value=_selectedUid />


<div class="bg-dark text-white p-2 m-2">
    <pre>Selected: @_selectedItem?.Vendor_Name</pre>
</div>

@code {
    private IEnumerable<Vendor> _vendorItems = Enumerable.Empty<Vendor>();

    private Guid? _selectedUid;
    private Vendor? _selectedItem => _vendorItems.FirstOrDefault(item => item.Vendor_Key == _selectedUid);

    protected async override Task OnInitializedAsync()
    {
        _vendorItems = await DataProvider.GetVendorsAsync();
    }
}
MrC aka Shaun Curtis
  • 19,075
  • 3
  • 13
  • 31
  • I can't see any issues in the code either! I had done something similar to what you describe above but liked the notion of isolating each control. It meant that changing the code for that one control didn't mean having to risk and test every drop down list box in the app again. That being said I may revisit this concept. While I do not mind the tedium of coding and even maintaining separate controls, two controls written exactly the same do not always work the same. (My OP is an example). Perhaps a common control may not "resolve" the issue but provide a means around it. – Gary O. Stenstrom Apr 14 '23 at 14:16
0

First of all, remove "IsLoaded" flag from the loading operation itself and set it in OnInitializedAsync. Your OnInitializedAsync should look like this: There is no need to call StateHasChanged from OnInitializedAsync.

protected async override Task OnInitializedAsync()
{
    IsLoaded = false;
    await this.SetVendorList();
    IsLoaded = true; 
}

Second, in your Test component, you are not applying the loading flags - i.e. your loading of "Site" parameter is happening asynchronously, but the Component is rendering only once. Consequently, your dropdown component is receiving a "null" value and as conditioned in your fetch function, the if (Site != null) condition is never met, subsequently IsLoaded is never set to true.

So just like in your drop down component, add loading flags in your test components as well. That should fix the problem.

Mayur Ekbote
  • 1,700
  • 1
  • 11
  • 14
0

This is my second answer because, at this point, I don't want to delete the first.

On reviewing your code in more detail, I believe the problem lies here:

    protected Sites site { get; set; }

    protected override async Task OnInitializedAsync()
    {
        await SetSite();
        await SetContract();
    }

You're not handling nulls correctly. If Nullable is enabled this line should generate a warning:

    protected Sites site { get; set; }

You should be declaring it like this because it's can be null (it's not set in ctor).

    protected Sites? site { get; set; }

This line in OnInitializedAsync yields to the ComponentBase UI Event handler,

    await SetSite();

and it does the first render of the component (and it's sub components).

This gets rendered:

<ContractVendorSelect Id="ddlContractVendor" 
        Label="Vendor" 
        Contract="contract" 
        Site="site">
</ContractVendorSelect>

OnInitializedAsync on ContractVendorSelect is run and in SetVendorList, site is null [the continuation on await SetSite in Test.razor hasn't yet run]. The code in OnInitializedAsync in ContractVendorSelect is all synchronous code till you hit the first await that actually yields. The code block that sets things up never gets run. You can test this by adding a Debug.WriteLine in the block.

   private async Task SetVendorList()
    {
        if (Site != null)
        {
            //this is never run
        }
    }
 

This is a typical case where you need to get data before any rendering takes place. You either wrap all the markup in Test.Razor in a isLoaded checker or load stuff before any rendering takes place. I do the latter like this.

private bool _firstLoad = true;

public override async Task SetParametersAsync(ParameterView parameters)
{
    parameters.SetParameterProperties(this);

    if (_firstLoad)
        await LoadDataAsync();
    
    _firstLoad = false;
    await base.SetParametersAsync(ParameterView.Empty);
}

and then:

public async ValueTask LoadDataAsync()
{
        await SetSite();
        await SetContract();
}
MrC aka Shaun Curtis
  • 19,075
  • 3
  • 13
  • 31
0

Your problem lies on the initial value of sites and the fact you are calling OnInitializedAsync. There is the chance your code OnInitializedAsync is being called BEFORE Site has any value (specially if it gets its value from an async function), and OnInitializedAsync is a function that is called only once in the lifetime. If you missed it, there is no comming back.

(Also your syntax on StateHasChanged is a little bit odd, I haven't seen that before)

Here you have two approaches I have used.

  1. use the boolean approach on OnAfterRenderAsync instead

     protected bool PendingLoad { get; set; } = true;
     protected bool HasInitialized { get; set; } = false;
    
     protected async override Task OnInitializedAsync()
     {
         HasInitialized=true;
     }
     protected async override Task OnAfterRenderAsync(bool firstRender) // called every time something renders
     {
         if (PendingLoad && HasInitialized)
         {
             HasInitialized=true;
             await this.SetVendorList();
             PendingLoad=false;
             await InvokeAsync(StateHasChanged);
         }
     }
    
  2. Call SetParametersAsync(ParameterView parameters)

     Sites oldSite = null; 
     protected async override Task SetParametersAsync(ParameterView parameters) // called every time a parameter is set
     {
         // this function might be called a lot and on any parameter set, do something to ensure your this is not executed unnecesarily
    
         if (Site != oldSited)
         {
             oldSite = Site;
             await this.SetVendorList();
             await InvokeAsync(StateHasChanged);
         }
     }
    
J Pablo F
  • 31
  • 3