New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question: How do you reset the selected index of a dependent select element? #887

Closed
mikebrind opened this Issue May 25, 2018 · 8 comments

Comments

Projects
None yet
3 participants
@mikebrind
Copy link

mikebrind commented May 25, 2018

I have been playing with cascading dropdown lists, and have noticed that the dependent dropdown retains the selectedIndex value when the primary dropdown option is changed and the dependent is populated with a different set of options. I have used both the bind syntax and the onchange option with the same result.

To repro, I used the ASP.NET Core Hosted template and added a couple of classes to the Shared project:

public class Author
{
    public int AuthorId { get; set; }
    public string Name { get; set; }
    public ICollection<Book> Books { get; set; }
}

public class Book
{
    public int BookId { get; set; }
    public string Title { get; set; }
    public int YearPublished { get; set; }
    public decimal Price { get; set; }
    public Author Author { get; set; }
}

Then a controller to the Server project:

using BlazorTests.Shared;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;

namespace BlazorTests.Server.Controllers
{
    [Produces("application/json")]
    [Route("api/book")]
    public class BookController : Controller
    {
        private static readonly List<Author> authors = new List<Author>{
            new Author{
                AuthorId = 1, Name = "Tom Clancy", Books = new List<Book>
                {
                    new Book{BookId = 1, Title = "Sum of all Fears",YearPublished = 1991, Price = 7.48m},
                    new Book{BookId = 2, Title = "Rainbow Six", YearPublished = 1998, Price = 6.99m},
                    new Book{BookId = 3, Title = "Hunt for Red October", YearPublished = 1984, Price = 4.99m}
                }
            },
            new Author{
                AuthorId = 2, Name = "Stephen King", Books = new List<Book>
                {
                    new Book{BookId = 4, Title = "Carrie", YearPublished = 1974, Price = 5.99m},
                    new Book{BookId = 5, Title = "The Stand", YearPublished = 1978, Price = 4.99m},
                    new Book{BookId = 6, Title = "Black House", YearPublished = 2001, Price = 5.99m},
                    new Book{BookId = 7, Title = "It", YearPublished = 1986, Price = 6.99m}
                }
            },
            new Author{
                AuthorId = 3, Name = "Robert Ludlum", Books = new List<Book>
                {
                    new Book{BookId = 8, Title = "The Bourne Ultimatum", YearPublished = 1990, Price = 5.99m},
                    new Book{BookId = 9, Title = "The Holcroft Covenant", YearPublished = 1978, Price = 4.99m},
                    new Book{BookId = 10, Title = "The Rhineman Exchange", YearPublished = 1974, Price = 4.99m}
                }
            }
        };

        [HttpGet]
        public IEnumerable<Author> Get()
        {
            return authors;
        }
    }
}

And finally a Books.cshtml component to the Client project:

@using BlazorTests.Shared
@page "/books"
@inject HttpClient http

<h1>Books</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (authors == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <select id="authors" onchange="@AuthorSelectionChanged">
        <option></option>
        @foreach (var author in authors)
        {
            <option value="@author.AuthorId">@author.Name</option>
        }
    </select>
}
@if (books != null)
{
    <select id="books" onchange="@BookSelectionChanged">
        <option></option>
        @foreach (var book in books)
        {
            <option value="@book.BookId">@book.Title</option>
        }
    </select>
}
@if(selectedBook != null)
{
    <div>
        Title: @selectedBook.Title<br />
        Year published: @selectedBook.YearPublished<br />
        Price: @selectedBook.Price
    </div>
}


@functions {

    Author[] authors;
    Book[] books;
    Book selectedBook;

    protected override async Task OnInitAsync()
    {
        authors = await http.GetJsonAsync<Author[]>("/api/book");
    }


    void AuthorSelectionChanged(UIChangeEventArgs e)
    {
        books = null;
        if (int.TryParse(e.Value.ToString(), out int id))
        {
            books = authors.First(a => a.AuthorId == id).Books.ToArray();
        }

    }

    void BookSelectionChanged(UIChangeEventArgs e)
    {
        if (int.TryParse(e.Value.ToString(), out int id))
        {
            selectedBook = books.FirstOrDefault(b => b.BookId == id);
        }
        else
        {
            selectedBook = null;
        }
    }
}

Select an author, then select a book. Then select a different author and the books option at the selected index of the previous choice is selected. The only time this is not the case is when the author is changed after selecting the 4th book in the Stephen King list. There are only 3 books assigned to the other authors.

Ideally, I'd like to be able to have the selectedIndex of the dependent set to -1 when the Author selection is changed. How do I do that?

@Andrzej-W

This comment has been minimized.

Copy link

Andrzej-W commented May 26, 2018

It looks there is a bug in Blazor. Blazor team - please read carefully to the end of this post.

First we have to fix code. Add one line in AuthorSelectionChanged function

void AuthorSelectionChanged(UIChangeEventArgs e)
{
    books = null;
    selectedBook = null; // <= new line
    if (int.TryParse(e.Value.ToString(), out int id))
    {
        books = authors.First(a => a.AuthorId == id).Books.ToArray();
    }
}

Then add selected attribute to <option>

<select id="books" onchange="@BookSelectionChanged">
  <option selected="@(selectedBook == null)"></option>
  @foreach (var book in books)
  {
    <option value="@book.BookId" selected="@(book.BookId == selectedBook?.BookId)">@book.Title</option>
  }
</select>

After all the changes program should work and it works as expected in Chrome and Edge, but does not work in Firefox 60.0.1 (tested on Windows 10 x64), ie selected item in "books" combo box is not changed to the first (empty) option.

I suppose there is a serious bug in Blazor. Here is the minimal code you can use to make a few tests and observe some problems in every browser.

<p>Enter number beetwen 0 and 5 and press Enter to select option in combobox</p>
<input bind="@optionToSelect" /><br />
<select id="combo">
    @for (int i = 0; i <= 5; i++)
    {
        <option value="@i" selected="@(i == optionToSelect)">@($"option {i}")</option>
    }
</select>
@functions
{
int optionToSelect;
}

Try to enter number in the input box and press Enter. Everything works as expected - selected item is consistent with the number. Refresh the page to be sure that everything is in the initial state. Select "option 1" in combo box. Click on input field, enter 2 and press Enter. Everything works as expected. Enter 1 and press Enter. You will see that option 0 is selected. Problem is visible in all 3 tested browsers. Select all items in combo box (I mean select 0, select 1, select 2, ...) and try to enter something in the input box. Now application doesn't work at all, ie it is impossible to programmatically change selected item in the combo box.

@mikebrind

This comment has been minimized.

Copy link

mikebrind commented May 26, 2018

Thanks @Andrzej-W. Your workaround is partially successful but it explicitly sets the selection option, The behaviour I expect is that the dependent dropdown has a selected index of -1 when the selection is changed in the primary dropdown.

@Andrzej-W

This comment has been minimized.

Copy link

Andrzej-W commented May 26, 2018

@mikebrind We don't have direct access (from C# code) to full DOM API yet. If you really want to set selectedindex you can use JavaScript interoperability. In this blog post https://blogs.msdn.microsoft.com/webdev/2018/05/02/blazor-0-3-0-experimental-release-now-available/ scroll to "Capturing references to DOM elements" and you will find full example.

@mikebrind

This comment has been minimized.

Copy link

mikebrind commented May 26, 2018

Thanks, that works nicely and answers the question in the title of the issue. I've read that article before but seem to have overlooked that section.

I'll leave this issue open while the Blazor team determine whether they need to take any action based on the behaviour I would expect.

@SteveSandersonMS

This comment has been minimized.

Copy link
Member

SteveSandersonMS commented May 30, 2018

Thanks for reporting this, @mikebrind, and thanks @Andrzej-W for your investigation.

The original code posted by @mikebrind behave as it should (even though not as @mikebrind wanted). The code doesn't contain any reason for the child dropdown's selected index to change when its contents change. Changing the author changes the list of books options, but that is no reason for the parent element's selectedIndex to change.

@Andrzej-W's suggestion does move towards the solution, but itself has an issue. The problem is that it's trying to dynamically add and remove selected attributes on the <option> elements. This is a dangerous thing to do, because different browsers respond to this differently. According to spec, browsers are meant to ignore selected attribute additions/removals once the user has interacted with the select box. Firefox implements this spec correctly, whereas Edge and Chrome do not (this comment explains in more detail). I know the Edge/Chrome behavior is more convenient in this situation but it's not ideal to depend on what could be regarded as a browser bug.

Fortunately there's a simpler way to resolve this. Instead of trying to add/remove selected attributes on the <options> elements, you can just set a value on the parent <select>. This avoids taking a dependency on a nonstandard browser quirk, plus is just less code to write. Here's an updated version of your code that has the behavior you want:

@using BlazorTests.Shared
@page "/books"
@inject HttpClient http

<h1>Books</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (authors == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <select id="authors" onchange="@AuthorSelectionChanged">
        <option></option>
        @foreach (var author in authors)
        {
            <option value="@author.AuthorId">@author.Name</option>
        }
    </select>
}
@if (books != null)
{
    <select id="books" value=@selectedBook?.BookId onchange="@BookSelectionChanged">
        <option value="0"></option>
        @foreach (var book in books)
        {
            <option value="@book.BookId">@book.Title</option>
        }
    </select>
}
@if(selectedBook != null)
{
    <div>
        Title: @selectedBook.Title<br />
        Year published: @selectedBook.YearPublished<br />
        Price: @selectedBook.Price
    </div>
}


@functions {

    Author[] authors;
    Book[] books;
    Book selectedBook;

    protected override async Task OnInitAsync()
    {
        authors = await http.GetJsonAsync<Author[]>("/api/book");
    }

    void AuthorSelectionChanged(UIChangeEventArgs e)
    {
        books = null;
        selectedBook = null;
        if (int.TryParse(e.Value.ToString(), out int id))
        {
            books = authors.First(a => a.AuthorId == id).Books.ToArray();
        }
    }

    void BookSelectionChanged(UIChangeEventArgs e)
    {
        if (int.TryParse(e.Value.ToString(), out int id))
        {
            selectedBook = books.FirstOrDefault(b => b.BookId == id);
        }
        else
        {
            selectedBook = null;
        }
    }
}
@SteveSandersonMS

This comment has been minimized.

Copy link
Member

SteveSandersonMS commented May 30, 2018

Aside from all this, I want to suggest an alternative way to write this code. If it was me, I wouldn't want to be using the onchange events, parsing ints, and generally relying on the DOM to track the selected index of the dropdowns. Instead of all that, I'd prefer to model the selections in C# and use Blazor's two-way bindings to sync with the DOM.

Here's an example:

@using BlazorTests.Shared
@page "/books"
@inject HttpClient http

<h1>Books</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (authors == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <select bind="SelectedAuthorId">
        <option value=@(0)></option>
        @foreach (var author in authors)
        {
            <option value="@author.AuthorId">@author.Name</option>
        }
    </select>
}

@if (SelectedAuthorId != default)
{
    var books = authors.Single(x => x.AuthorId == SelectedAuthorId).Books;

    <select bind="SelectedBookId">
        <option value=@(0)></option>
        @foreach (var book in books)
        {
            <option value="@book.BookId">@book.Title</option>
        }
    </select>

    var selectedBook = books.FirstOrDefault(x => x.BookId == SelectedBookId);
    @if(selectedBook != null)
    {
        <div>
            Title: @selectedBook.Title<br />
            Year published: @selectedBook.YearPublished<br />
            Price: @selectedBook.Price
        </div>
    }
}

@functions {
    Author[] authors;

    // Track the selected author ID, and when it's written to, reset SelectedBookId
    int _selectedAuthorId;
    int SelectedAuthorId
    {
        get => _selectedAuthorId;
        set
        {
            _selectedAuthorId = value;
            SelectedBookId = default;
        }
    }

    int SelectedBookId { get; set; }

    protected override async Task OnInitAsync()
    {
        authors = await http.GetJsonAsync<Author[]>("/api/book");
    }
}

I think this is simpler because it doesn't involve manual event handling. But of course both approaches are valid.

Closing, then, because I don't think there's a bug here.

I know that, as per @Andrzej-W's code samples above, there could be an argument that Blazor should do something to change the surprising thing that browsers do when you dynamically add/remove selected attributes on <option> elements. If more people run into this over time, we'll consider doing so. But otherwise we don't necessarily want to fight against browser behaviors when there are relatively clean ways of implementing the desired result. We'll continue to watch this over time.

@mikebrind

This comment has been minimized.

Copy link

mikebrind commented May 30, 2018

Thanks, @SteveSandersonMS for yanking me back from that particular rabbit hole 😁. Thanks also for the two-way binding example. I played with something similar shortly after posting the issue. I agree - it's a much nicer way of doing things.

@Andrzej-W

This comment has been minimized.

Copy link

Andrzej-W commented May 30, 2018

@SteveSandersonMS thank you very much for your time and smart solution to the problem.

Should you do something in Blazor to support dynamic adding/removing of selected attribute? I think it is not necessary. I'm a desktop developer. I have some experience in ASP.NET Core but I have never written any dynamic web application (HTML pages with JavaScript code, Vue, Angular, etc.).
Here https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option we can read

selected If present, this Boolean attribute indicates that the option is initially selected.

It is clearly stated that option is initially selected. I have to change the way I think about HTML page in Blazor - it is rendered and then only smart modifications are applied. Taking this into account I have to agree with you - there is no bug in Blazor and you don't have to change anything.

On the other hand I believe that in the near future Blazor will be used by thousands (or millions) of desktop developers. It is so amazing and revolutionary technology!!! These guys and gals will have a lot of problems with browsers' incompatibilities. It would be nice to have a programming environment which masks inconsistency, but it is probably a lot of work. If there is a big inconsistency between browser and there is no simple workaround you can try to fix it in Blazor. If there is a simple solution it will be easy to find it on Stackoverflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment