Data Grid

Display typed data with columns, sorting, selection, pagination, row details, and inline editing.

The data grid is part of StellarAdmin.UI and uses the standard tag helper setup, theme stylesheet, and JavaScript bundle.

Setup

Register the tag helpers alongside your application's MVC or Razor Pages services:

builder.Services.AddStellarAdminUI();

Add these directives to _ViewImports.cshtml:

@using StellarAdmin.UI
@addTagHelper *, StellarAdmin.UI

Example

    var selectedPageSize = int.TryParse(Context.Request.Query["pageSize"], out var requestedPageSize)
        ? (int?)Math.Max(requestedPageSize, 1)
        : null;
    var pageSize = selectedPageSize ?? 3;
    var currentPage = int.TryParse(Context.Request.Query["pageNo"], out var requestedPage)
        ? Math.Max(requestedPage, 1)
        : 1;
    var sortField = Context.Request.Query["sortBy"].ToString();
    var sortDirection = Context.Request.Query["sortDir"] == "desc"
        ? DataGridSortDirection.Descending
        : DataGridSortDirection.Ascending;

    IEnumerable<Booking> bookings = StaticData.Bookings;
    bookings = (sortField.ToLowerInvariant(), sortDirection) switch
    {
        ("destination", DataGridSortDirection.Ascending) => bookings.OrderBy(b => b.Destination),
        ("destination", DataGridSortDirection.Descending) => bookings.OrderByDescending(b => b.Destination),
        ("amount", DataGridSortDirection.Ascending) => bookings.OrderBy(b => b.Amount),
        ("amount", DataGridSortDirection.Descending) => bookings.OrderByDescending(b => b.Amount),
        _ => bookings
    };
    var pageOfBookings = bookings.Skip((currentPage - 1) * pageSize).Take(pageSize);
    var totalPages = (int)Math.Ceiling(StaticData.Bookings.Length / (double)pageSize);
}
<sa-data-grid items="pageOfBookings" key-field="Id">
    <sa-data-grid-selection name="selectedBookings" id="booking-grid-selection"/>
    <sa-data-grid-sort sort-field="@sortField" sort-direction="sortDirection"
                       asp-page="/DataGrid/Index"
                       asp-route-sortBy="{sortField}" asp-route-sortDir="{sortDirection}"
                       preserve-query="true" preserve-query-except="pageNo"/>
    <sa-data-grid-column title="Booking #" field="Id" class="font-medium"/>
    <sa-data-grid-column title="Status" field="Status"/>
    <sa-data-grid-column title="Destination" field="Destination" sortable="true"/>
    <sa-data-grid-column title="Amount" field="Amount" format="{0:C}" sortable="true" class="text-right"/>
    <sa-data-grid-empty>No bookings found.</sa-data-grid-empty>
    <sa-data-grid-pager page="currentPage" total-pages="totalPages"
                        page-size="pageSize" selected-page-size="selectedPageSize"
                        total-items="StaticData.Bookings.Length"
                        page-size-options="3,5,10"
                        asp-page="/DataGrid/Index"
                        asp-route-pageNo="{page}" asp-route-pageSize="{pageSize}"
                        preserve-query="true"/>
</sa-data-grid>
<sa-button id="booking-grid-delete" variant="ButtonVariant.Destructive" class="mt-4" hidden>
    Delete selected
</sa-button>
<script>
    (() => {
        const selection = document.getElementById("booking-grid-selection");
        const deleteButton = document.getElementById("booking-grid-delete");

        const syncButton = () => deleteButton.hidden = selection.selectedValues.length === 0;

        selection.addEventListener("rowselectionchange", syncButton);
        customElements.whenDefined("sel-table").then(syncButton);

        deleteButton.addEventListener("click", () =>
            alert(`Delete bookings:\n${selection.selectedValues.join("\n")}`));
    })();
</script>

Selection works in this static preview. Sorting and pagination are disabled in the static preview and require the running DocsSamples app at /DataGrid; the sample reads query parameters and applies sorting and paging on the server. Supply the current page of items to sa-data-grid, define columns with sa-data-grid-column, and provide sorting and pagination state through the corresponding child tags.

Row details

Add an <sa-data-grid-row-detail> to let rows expand and show more information below them. Its content is a row template like a column's <sa-data-grid-item-template>: it renders once per row, with the row's item available through Html.GridItem<T>(), so the details can show more fields of the same item or rows from another source, such as the itinerary in a nested grid here. The grid adds a column of toggles, with an expand-all toggle in its header. To render rows expanded, set key-field to the property that identifies a row and expanded-keys to the keys of the rows to open.

<sa-data-grid items="StaticData.Bookings" key-field="Id" class="w-full">
    <sa-data-grid-column title="Booking #" field="Id" class="font-medium"/>
    <sa-data-grid-column title="Status" field="Status"/>
    <sa-data-grid-column title="Destination" field="Destination"/>
    <sa-data-grid-column title="Amount" field="Amount" format="{0:C}" class="text-right"/>
    <sa-data-grid-row-detail expanded-keys='new[] { "TRP-4821" }'>
        @{ var details = StaticData.DetailsFor(Html.GridItem<Booking>().Id); }
        <div class="flex flex-col gap-3">
            <p>
                <span class="font-medium">@details.Traveller</span>
                <span class="text-muted-foreground">· @details.Party · @details.Email</span>
            </p>
            <sa-data-grid items="details.Itinerary">
                <sa-data-grid-column title="Date" field="Date"/>
                <sa-data-grid-column title="Type" field="Type"/>
                <sa-data-grid-column title="Description" field="Description"/>
                <sa-data-grid-column title="Amount" field="Amount" class="text-right"/>
            </sa-data-grid>
        </div>
    </sa-data-grid-row-detail>
</sa-data-grid>

The details use the same behavior and events as table row details, including loading them with htmx. To place the toggle yourself, such as in an actions column beside an edit button, set toggle="DataGridRowDetailToggle.None" and add an <sa-table-row-detail-toggle> to that column's <sa-data-grid-item-template>. The emphasis, divider and inset settings look as they do on a table; see the row details divider example.

<sa-data-grid-row-detail>

Prop

Type

Inline editing

Rows can be edited in place. Set key-field to the property that identifies a row, and list the keys of the rows to edit in editing-keys. Those rows render each column's <sa-data-grid-edit-template> instead of its value, with the row's item available through Html.GridItem<T>(); a column without an edit template keeps showing its value. Any number of rows can be in edit mode at once, and selection and row details keep working.

<sa-data-grid items="StaticData.Bookings.Take(3)" key-field="Id" editing-keys='new[] { "TRP-4822" }'>
    <sa-data-grid-selection name="selectedBookings"/>
    <sa-data-grid-column title="Booking #" field="Id" class="font-medium"/>
    <sa-data-grid-column title="Status" field="Status">
        <sa-data-grid-edit-template>
            <sa-select class="sa-cell-editor" wrap-in-field="false" size="SelectSize.Small"
                       asp-for="@Html.GridItem<Booking>().Status" asp-items="Html.GetEnumSelectList<BookingStatus>()" aria-label="Status"/>
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
    <sa-data-grid-column title="Destination" field="Destination">
        <sa-data-grid-edit-template>
            <sa-input class="sa-cell-editor" wrap-in-field="false" asp-for="@Html.GridItem<Booking>().Destination" aria-label="Destination"/>
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
    <sa-data-grid-column title="Amount" field="Amount" format="{0:C}" class="text-right">
        <sa-data-grid-edit-template>
            <sa-input class="sa-cell-editor text-right" wrap-in-field="false" asp-for="@Html.GridItem<Booking>().Amount" aria-label="Amount"/>
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
</sa-data-grid>

Fields in an editing row are named after the row's key, so asp-for="@Html.GridItem<Booking>().Destination" posts as rows[TRP-4822].Destination. Bind a row's values with that prefix, for example await TryUpdateModelAsync(booking, $"rows[{key}]"). Give an editor the sa-cell-editor class so it fills its cell without changing the row's height, wrap a switch or checkbox in a <span class="sa-cell-check">, and label each editor with aria-label, since the cell has no label of its own. While a row is in edit mode the columns keep their widths, so the grid doesn't shift as editors appear.

New row

Pass an item to new-item to render it as a new row in edit mode, above the other rows. Its key is new, so its fields post as rows[new].Destination. Columns without an edit template are empty in the new row.

<sa-data-grid items="StaticData.Bookings.Take(3)" key-field="Id"
              new-item='new Booking("", BookingStatus.Pending, "", 0)'>
    <sa-data-grid-column title="Booking #" field="Id" class="font-medium"/>
    <sa-data-grid-column title="Status" field="Status">
        <sa-data-grid-edit-template>
            <sa-select class="sa-cell-editor" wrap-in-field="false" size="SelectSize.Small"
                       asp-for="@Html.GridItem<Booking>().Status" asp-items="Html.GetEnumSelectList<BookingStatus>()" aria-label="Status"/>
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
    <sa-data-grid-column title="Destination" field="Destination">
        <sa-data-grid-edit-template>
            <sa-input class="sa-cell-editor" wrap-in-field="false" asp-for="@Html.GridItem<Booking>().Destination"
                      placeholder="City, country" aria-label="Destination"/>
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
    <sa-data-grid-column title="Amount" field="Amount" format="{0:C}" class="text-right">
        <sa-data-grid-edit-template>
            <sa-input class="sa-cell-editor text-right" wrap-in-field="false" asp-for="@Html.GridItem<Booking>().Amount" aria-label="Amount"/>
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
</sa-data-grid>

Add row

An <sa-data-grid-add-row> renders a full-width row for a button that adds an item, first in the grid or, with position="DataGridAddRowPosition.Bottom", last. It also shows when the grid has no items, in which case the empty-state message steps aside for an added row, and it hides while a new row (the key new) is in edit mode.

<sa-data-grid items="StaticData.Bookings.Take(3)" key-field="Id">
    <sa-data-grid-column title="Booking #" field="Id" class="font-medium"/>
    <sa-data-grid-column title="Status" field="Status"/>
    <sa-data-grid-column title="Destination" field="Destination"/>
    <sa-data-grid-column title="Amount" field="Amount" format="{0:C}" class="text-right"/>
    <sa-data-grid-add-row position="DataGridAddRowPosition.Bottom">
        <sa-button variant="ButtonVariant.Ghost" size="ButtonSize.Small">
            <sa-icon name="plus"/>
            Add booking
        </sa-button>
    </sa-data-grid-add-row>
</sa-data-grid>

Editor templates

An edit template can render any Razor: tag helpers, Html.EditorFor with an editor template picked by the property's [UIHint] or by name, or a partial with <partial for="...">. Field names keep the row's prefix in each case.

<sa-data-grid items="StaticData.Bookings.Take(3)" key-field="Id" editing-keys='new[] { "TRP-4822" }'>
    <sa-data-grid-column title="Booking #" field="Id" class="font-medium"/>
    <sa-data-grid-column title="Status" field="Status">
        <sa-data-grid-edit-template>
            @* Picked by the property's [UIHint("BookingStatus")]: EditorTemplates/BookingStatus.cshtml *@
            @Html.EditorFor(_ => Html.GridItem<Booking>().Status)
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
    <sa-data-grid-column title="Destination" field="Destination">
        <sa-data-grid-edit-template>
            <partial name="_DestinationEditor" for="@Html.GridItem<Booking>().Destination"/>
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
    <sa-data-grid-column title="Amount" field="Amount" format="{0:C}" class="text-right">
        <sa-data-grid-edit-template>
            @* Named: EditorTemplates/Currency.cshtml *@
            @Html.EditorFor(_ => Html.GridItem<Booking>().Amount, "Currency")
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
</sa-data-grid>

Validation errors

Render the posted values again with the errors under their cells: add an <sa-field-error class="sa-cell-error"> for each field. It shows only when the field is invalid, and model state errors are keyed by the prefixed name, such as rows[TRP-4822].Amount.

    // The posted values, rendered again with the errors that a Save handler found
    var bookings = StaticData.Bookings.Take(3)
        .Select(booking => booking.Id == "TRP-4822" ? booking with { Destination = "", Amount = -50 } : booking);
}
<sa-data-grid items="bookings" key-field="Id" editing-keys='new[] { "TRP-4822" }'>
    <sa-data-grid-column title="Booking #" field="Id" class="font-medium"/>
    <sa-data-grid-column title="Destination" field="Destination">
        <sa-data-grid-edit-template>
            <sa-input class="sa-cell-editor" wrap-in-field="false" asp-for="@Html.GridItem<Booking>().Destination" aria-label="Destination"/>
            <sa-field-error class="sa-cell-error" asp-for="@Html.GridItem<Booking>().Destination"/>
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
    <sa-data-grid-column title="Amount" field="Amount" format="{0:C}" class="text-right">
        <sa-data-grid-edit-template>
            <sa-input class="sa-cell-editor text-right" wrap-in-field="false" asp-for="@Html.GridItem<Booking>().Amount" aria-label="Amount"/>
            <sa-field-error class="sa-cell-error" asp-for="@Html.GridItem<Booking>().Amount"/>
        </sa-data-grid-edit-template>
    </sa-data-grid-column>
</sa-data-grid>

Row requests with htmx

The grid renders only what it is given, so the requests that edit, save and add rows are the application's. Render the same grid with rows-only="true" for a row request: it returns only the rows, without the grid, its table and its header, so a response can replace a single <tr>. A row rendered in edit mode this way has no details row; the row it replaced keeps its own.

  • Edit: load the item and render it with its key in editing-keys, swapped in for the row with hx-target="closest tr" hx-swap="outerHTML".
  • Save: post the row's fields with hx-include="closest tr", since a form can't hold a table row, and send the antiforgery token in hx-headers. On success, render the saved item as a display row; on failure, render the editing row again with the errors. For a row whose details row stayed in the page, select only the row with hx-select="tr[data-key]" and refresh the details' content with hx-select-oob.
  • Add: render the new item with new-item and insert the row where it belongs, for example with hx-swap="afterend" from a button in an add row.
  • Cancel: mark the button data-row-cancel. The grid puts the row it replaced back without a request, or removes a new row. Only a row that was rendered in edit mode with the page has no row to restore, so its Cancel button goes to the server.
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@{
    var headers = $$"""{"RequestVerificationToken": "{{Antiforgery.GetAndStoreTokens(Context).RequestToken}}"}""";
}

<sa-data-grid-edit-template>
    <sa-button variant="ButtonVariant.Outline" size="ButtonSize.Small" data-row-cancel
               hx-get="?handler=Cancel&id=@Html.GridItem<Tour>().Id" hx-target="closest tr" hx-swap="outerHTML">Cancel</sa-button>
    <sa-button size="ButtonSize.Small" hx-post="?handler=Save&id=@Html.GridItem<Tour>().Id"
               hx-include="closest tr" hx-headers="@headers" hx-target="closest tr" hx-swap="outerHTML">Save</sa-button>
</sa-data-grid-edit-template>

<sa-data-grid> editing attributes

Prop

Type

<sa-data-grid-add-row>

Prop

Type

On this page