Simple expandable table with Blazor

Viewed 5189

I want to create a simple expandable table in Blazor WebAssembly. I added some HTML code like the following:

<table class="table table-hover">
  <thead>
    <tr>
      <th>#</th>
      <th>User</th>
      <th>Date</th>
      <th>Status</th>
      <th>Reason</th>
    </tr>
  </thead>
  <tbody>
    <tr data-widget="expandable-table" aria-expanded="false">
      <td>183</td>
      <td>John Doe</td>
      <td>11-7-2014</td>
      <td>Approved</td>
      <td>Lorem Ipsum is simply dummy text</td>
    </tr>
    <tr class="expandable-body">
      <td colspan="5">
        <p>
          Lorem Ipsum is simply dummy text
        </p>
      </td>
    </tr>
  </tbody>
</table>

The row with details is always collpased also if I click on it. Is there a simple way to implement it without using external component? If no, what component do you recommend?

An example of what I mean with "Expandable table" is here.

5 Answers

With Bootstrap, the built in jQuery/JavaScript code is not going to work out-of-the-box like traditional MVC pages do.

If you want to expand/collapse rows, you've got two options with Blazor.

  1. Invoke the Bootstrap jQuery/JavaScript by injecting IJSRuntime onto the page and calling InvokeAsync().

  2. Write your own expand/collapse component and wrap each row in it - something like

// CollapsibleTableRow.

<tr @onclick=Toggle>
   @if(_show)
   {
       @ChildContent
   }
</tr>


@code 
{
    [Parameter] public RenderFragment ChildContent { get; set; }
    private bool _show { get; set; } = false;
    
    private void Toggle()
    {
        _show = !_show;
    }
}

Use

<table>
    <tbody>
         @foreach(var item in items)
         {
             <CollapsibleTableRow>
                 <td>@item.Thing</td>
                 <td>@item.Thing2</td>
             </CollapsibleTableRow>
         }
    </tbody>
</table>

I came up with a solution based on the default "WeatherForecast" that comes from the Blazor Server template. The basic idea is to put a flag in the model that is used to represent table data.

WeatherForecast.cs

public class WeatherForecast
{
    public DateTime Date { get; set; }

    public int TemperatureC { get; set; }

    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);

    public string Summary { get; set; }

    //new fields added below
    public bool IsRowExpanded { get; set; } = false;

    public string ExpandableContent { get; set; } = "Lorem Ipsum";
}

FetchData.razor

<table class="table">
    <thead>
        <tr>
            <th>Date</th>
            <th>Temp. (C)</th>
            <th>Temp. (F)</th>
            <th>Summary</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var forecast in forecasts)
        {
            <tr @onclick="() => forecast.IsRowExpanded = !forecast.IsRowExpanded">
                <td>@forecast.Date.ToShortDateString()</td>
                <td>@forecast.TemperatureC</td>
                <td>@forecast.TemperatureF</td>
                <td>@forecast.Summary</td>
            </tr>
            if (forecast.IsRowExpanded)
            {
                <tr>@forecast.ExpandableContent</tr>
            }
        }
    </tbody>
</table>

When you click on a row (for example the 2nd row) it will look like this:

enter image description here

Updated answer. I added a little class to make it easier. The magic is in what I call a "state carrier variable," which lets you make the open state a property of the Person class instead of adding a separate list to track which rows are opened or not. It took some time to type it, so please remember to mark it "Answered" if you like it.

@page "/collapse"

<table class="table">
    <thead>
        <tr style="cursor:pointer;">
            <th>#</th>
            <th>User</th>
            <th>Date</th>
            <th>Status</th>
            <th>Reason</th>
        </tr>
    </thead>
    <tbody>
        @foreach (Person person in People)
        {
            <tr style="cursor:pointer" @onclick="()=>person.opened=!person.opened">
                <td>@person.id</td>
                <td>@person.name</td>
                <td>@person.birthday.ToString("dddd MMMM d, yyyy")</td>
                <td>@(person.approved ? "Approved" : "Denied")</td>
                <td>@person.reason</td>
            </tr>
            @if (person.opened)
            {
                <tr>
                    <td colspan="5">
                        <p>
                            Lorem Ipsum is simply dummy text
                        </p>
                    </td>
                </tr>
            }
        }
    </tbody>
</table>

@code {
    class Person
    {
        public int id;
        public string name;
        public DateTime birthday;
        public bool approved;
        public string info;
        public string reason;
        public bool opened;  // I call this a state carrier, because it's not really data.

        public Person(int ID, string Name, DateTime Birthday, bool Approved, string Reason) {
            id = ID; name = Name; birthday = Birthday; approved = Approved; reason = Reason;
        }
    }
    List<Person> People = new List<Person>();

    protected override void OnInitialized()
    {
        People.Add(new Person(183, "John Doe", new DateTime(2014, 7, 11), true, "Good worker."));
        People.Add(new Person(184, "Benjamin", new DateTime(1999, 9, 9), false, "Types too much."));
        People.Add(new Person(1, "Jesus", new DateTime(1, 1, 1), true, "Really nice guy."));
    }
}

The pattern I've opted for in places where I've needed to do this is to use generics and wrap around the underlying datatype to add UI specific functionality.

The definition of the generic class looks like this.

public class ExpandableItem<T>
{
    private readonly T _item;
    private bool _expanded;
    public ExpandableItem(T item)
    {
        _item = item;
    }

    public T Item { get => _item; }

    public bool IsExpanded{ get; set; }
}

The initialization then looks something like this:

List<ExpandableItem<Person>> People = new();

protected override void OnInitialized()
{
    People.Add(ExpandableItem<Person>(new Person(183, "John Doe", new DateTime(2014, 7, 11), true, "Good worker.")));
}

This results in the binding looking a little like this:

@foreach (ExpandableItem<Person> item in People)
{
    var person = item.Item;
    <tr style="cursor:pointer" @onclick="()=>item.IsExpanded=!item.IsExpanded">
        <td>@person.id</td>
        <td>@person.name</td>
    </tr>
    @if (item.IsExpanded)
    {
        <tr>
            <td colspan="5">
                <p>Lorem Ipsum is simply dummy text</p>
            </td>
         </tr>
     }
}

Doing it this way was the best method I could come up with for ensuring the DTO objects coming down from an API endpoint stayed clean of UI state properties. I found it also ensured consistency between pages, because the requirement to have selectable / expandable rows is common across a number of underlying types.

<tr>
    <td class="oi oi-elevator" @onclick="@(() => ToggleDetails(@report.EventId))"/>
</tr>

<tr style="@(_eventIds.Contains(report.EventId) ? "display:table-row;" : "display:none;")">
   <td colspan="6">
     <EventGridDetail _status=report.Status/> 
   </td>
</tr>

private void ToggleDetails(string eventId)
{
   if (_eventIds.Contains(eventId))
      _eventIds.Remove(eventId);
   else
     _eventIds.Add(eventId);
}
Related