diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetail.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetail.razor new file mode 100644 index 0000000000..aedb035731 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetail.razor @@ -0,0 +1,58 @@ + + + + + + + + + + +
Orders of @customer.Name
+ + + + + + + @context.Status + + + +
+
+ +@code { + private record Customer(int Id, string Name, string City); + private record Order(string OrderNumber, int CustomerId, DateTime OrderDate, decimal Amount, string Status); + + private IQueryable customers = new List + { + new(1, "Contoso Ltd.", "Seattle"), + new(2, "Fabrikam, Inc.", "Paris"), + new(3, "Northwind Traders", "London"), + new(4, "Adventure Works", "Brussels"), + }.AsQueryable(); + + private List orders = new() + { + new("ORD-1001", 1, new DateTime(2026, 5, 12), 1250.00m, "Delivered"), + new("ORD-1002", 1, new DateTime(2026, 6, 3), 480.50m, "Shipped"), + new("ORD-1003", 1, new DateTime(2026, 6, 28), 90.00m, "Pending"), + new("ORD-1004", 2, new DateTime(2026, 4, 21), 3200.00m, "Delivered"), + new("ORD-1005", 2, new DateTime(2026, 6, 15), 150.75m, "Cancelled"), + new("ORD-1006", 3, new DateTime(2026, 5, 30), 780.25m, "Delivered"), + new("ORD-1007", 3, new DateTime(2026, 6, 10), 2100.00m, "Shipped"), + new("ORD-1008", 3, new DateTime(2026, 7, 1), 55.99m, "Pending"), + new("ORD-1009", 4, new DateTime(2026, 6, 22), 640.00m, "Shipped"), + }; + + // The child grid is filtered with the master row's item (the customer) + private IQueryable OrdersOf(int customerId) + => orders.Where(o => o.CustomerId == customerId).AsQueryable(); +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailCustom.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailCustom.razor new file mode 100644 index 0000000000..7e2781a124 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailCustom.razor @@ -0,0 +1,76 @@ + + +
+ + + + + + + @(context.IsActive ? "Active" : "Inactive") + + + + + + +
@customer.Name
+ + + + City + @customer.City + + + + + Email + + + + + + Phone + @customer.Phone + + + + Satisfaction + + +
+
+
+
+
+ +@code { + private record Customer(int Id, string Name, string City, string Email, string Phone, int Satisfaction, bool IsActive); + + private IQueryable customers = new List + { + new(1, "Contoso Ltd.", "Seattle", "contact@contoso.com", "+1 206 555 0100", 92, true), + new(2, "Fabrikam, Inc.", "Paris", "hello@fabrikam.com", "+33 1 55 00 12 00", 78, true), + new(3, "Northwind Traders", "London", "sales@northwind.com", "+44 20 7946 0000", 64, false), + new(4, "Adventure Works", "Brussels", "info@adventure-works.com", "+32 2 555 01 23", 85, true), + }.AsQueryable(); +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailTwoLevels.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailTwoLevels.razor new file mode 100644 index 0000000000..1301b84b63 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Examples/DataGridMasterDetailTwoLevels.razor @@ -0,0 +1,76 @@ + + +
+ + + + + + + +
Orders of @customer.Name
+ + + + + + + +
Lines of order @order.OrderNumber
+ + + + + + +
+
+
+
+
+ +@code { + private record Customer(int Id, string Name, string City); + private record Order(string OrderNumber, int CustomerId, DateTime OrderDate); + private record OrderLine(string OrderNumber, string Product, int Quantity, decimal UnitPrice); + + private IQueryable customers = new List + { + new(1, "Contoso Ltd.", "Seattle"), + new(2, "Fabrikam, Inc.", "Paris"), + }.AsQueryable(); + + private List orders = new() + { + new("ORD-1001", 1, new DateTime(2026, 5, 12)), + new("ORD-1002", 1, new DateTime(2026, 6, 3)), + new("ORD-1004", 2, new DateTime(2026, 4, 21)), + }; + + private List lines = new() + { + new("ORD-1001", "Standing desk", 2, 450.00m), + new("ORD-1001", "Office chair", 2, 175.00m), + new("ORD-1002", "Monitor 27\"", 1, 320.50m), + new("ORD-1002", "HDMI cable", 4, 40.00m), + new("ORD-1004", "Laptop docking station", 10, 220.00m), + new("ORD-1004", "Wireless keyboard", 10, 100.00m), + }; + + // Each level filters its child grid with its own row item + private IQueryable OrdersOf(int customerId) + => orders.Where(o => o.CustomerId == customerId).AsQueryable(); + + private IQueryable LinesOf(string orderNumber) + => lines.Where(l => l.OrderNumber == orderNumber).AsQueryable(); +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md new file mode 100644 index 0000000000..4a7947d082 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DataGrid/Pages/DataGridMasterDetailPage.md @@ -0,0 +1,48 @@ +--- +title: Master / Detail +route: /DataGrid/MasterDetail +--- + +# Master / Detail + +The `FluentDataGrid` supports a master/detail view. Set the `RowDetails` template parameter to make each row +expandable through a chevron button shown in the first column. The template content is rendered in an extra row, +spanning all columns, directly below the expanded row. + +The template's `context` is the master row's item, so you can place a child `FluentDataGrid` inside it and filter +its items based on the master row. In this example, expanding a customer shows a child grid with that customer's +orders. + +Unlike the [hierarchical view](/DataGrid/Hierarchical) — where parent and child rows share the same item type and +the same columns within a single grid — the master/detail view displays data of a completely different structure: +the child grid defines its own columns, sorting and content, and is simply filtered by the master row's item. + +Rows can also be expanded and collapsed programmatically with the `ToggleRowDetailsAsync`, `ExpandRowDetailsAsync`, +`CollapseRowDetailsAsync`, `ExpandAllRowDetailsAsync` and `CollapseAllRowDetailsAsync` methods. The +`OnRowDetailsToggle` event callback is raised for every row whose expansion state actually changes — including once +per affected row when using `ExpandAllRowDetailsAsync`/`CollapseAllRowDetailsAsync` — but not for rows that were +already in the requested state. + +*Note: `RowDetails` cannot be combined with `Virtualize`.* + +{{ DataGridMasterDetail Files=Code:DataGridMasterDetail.razor }} + +## Any content as detail + +The detail template is not limited to a child `FluentDataGrid`: it can host any content. Because the template's +`context` is the master row's item, you can build a fully custom detail panel — cards, stacks, icons, links, +progress bars, buttons or any other component — bound to that item. In this example, expanding a customer shows a +contact card built from standard Fluent components instead of a nested grid. + +{{ DataGridMasterDetailCustom Files=Code:DataGridMasterDetailCustom.razor }} + +## Two levels of detail + +Since the detail template can contain any content, a child `FluentDataGrid` can itself define a `RowDetails` +template, giving as many nested levels as needed. In this example, expanding a customer shows its orders, and +expanding an order shows its order lines. + +Each level filters its own child grid with its own row item (the `Context` of each template is named explicitly — +`customer`, then `order` — to avoid ambiguity between the nested templates). + +{{ DataGridMasterDetailTwoLevels Files=Code:DataGridMasterDetailTwoLevels.razor }} diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor b/src/Core/Components/DataGrid/FluentDataGrid.razor index ebcd012f45..be2e2440de 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor @@ -108,15 +108,27 @@ { var rowClass = RowClass?.Invoke(item) ?? null; var rowStyle = RowStyle?.Invoke(item) ?? null; + var rowDetailsExpanded = RowDetails is not null && IsRowDetailsExpanded(item); @for (var colIndex = 0; colIndex < _columns.Count; colIndex++) { var col = _columns[colIndex]; + var isFirstColumn = colIndex == 0; string? tooltip = col.Tooltip ? @col.RawCellContent(item) : null; + @if (isFirstColumn && RowDetails is not null) + { + + + + + + + + } @if (col.HierarchicalToggle && item is IHierarchicalGridItem hierarchicalItem) { @@ -138,6 +150,25 @@ } + @if (rowDetailsExpanded) + { + string? detailsStyle = null; + string? detailsColspan = null; + if (DisplayMode == DataGridDisplayMode.Grid) + { + detailsStyle = $"grid-column: 1 / {_columns.Count + 1}"; + } + else + { + detailsColspan = _columns.Count.ToString(CultureInfo.InvariantCulture); + } + + + + @RowDetails!(item) + + + } } [ExcludeFromCodeCoverage(Justification = "This method is used virtualized mode and cannot be tested with bUnit currently.")] diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs index fd4b4ce2c6..c4213c030e 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.cs @@ -39,6 +39,7 @@ private enum ColumnHeaderUiKind internal const string EMPTY_CONTENT_ROW_CLASS = "empty-content-row"; internal const string LOADING_CONTENT_ROW_CLASS = "loading-content-row"; internal const string ERROR_CONTENT_ROW_CLASS = "error-content-row"; + internal const string ROW_DETAILS_ROW_CLASS = "row-details-row"; private ElementReference? _gridReference; private Virtualize<(int, TGridItem)>? _virtualizeComponent; @@ -58,6 +59,9 @@ private enum ColumnHeaderUiKind private bool _checkColumnResizing; private bool _checkColumnReordering; private bool _manualGrid; + + // Keys (as returned by ItemKey) of the rows whose RowDetails content is currently expanded + private readonly HashSet _expandedRowDetails = []; private readonly RenderFragment _renderColumnHeaders; private readonly RenderFragment _renderNonVirtualizedRows; private readonly RenderFragment _renderEmptyContent; @@ -347,6 +351,25 @@ public FluentDataGrid(LibraryConfiguration configuration) : base(configuration) [Parameter] public EventCallback OnCollapseAll { get; set; } + /// + /// Gets or sets the template that defines the detail content shown when a row is expanded (master/detail view). + /// + /// When set, each row displays an expand/collapse button in its first column. The expanded content is rendered + /// in an extra row spanning all columns, directly below the master row. The template's context is the row's + /// , so it can, for example, contain a child + /// whose items are filtered by the master row. + /// + /// Cannot be used together with . + /// + [Parameter] + public RenderFragment? RowDetails { get; set; } + + /// + /// Event callback for when a row's content is expanded or collapsed. + /// + [Parameter] + public EventCallback OnRowDetailsToggle { get; set; } + /// /// Event callback for when the grid's sort order changes. /// @@ -518,6 +541,11 @@ protected override async Task OnParametersSetAsync() throw new InvalidOperationException($"FluentDataGrid cannot use both {nameof(Virtualize)} and {nameof(MultiLine)} at the same time."); } + if (Virtualize && RowDetails is not null) + { + throw new InvalidOperationException($"FluentDataGrid cannot use both {nameof(Virtualize)} and {nameof(RowDetails)} at the same time."); + } + // Perform a re-query only if the data source or something else has changed var dataSourceHasChanged = !Equals(ItemsProvider, _lastAssignedItemsProvider) || !ReferenceEquals(Items, _lastAssignedItems); if (dataSourceHasChanged) @@ -1867,4 +1895,90 @@ private async Task ToggleExpandedAsync(TGridItem item) await RefreshDataAsync(); } } + + /// + /// Gets a value indicating whether the content of the specified is currently expanded. + /// + /// The item that holds the row's values. + public bool IsRowDetailsExpanded(TGridItem item) => _expandedRowDetails.Contains(ItemKey(item)); + + /// + /// Expands or collapses the content of the specified . + /// + /// The item that holds the row's values. + /// A representing the completion of the operation. + public async Task ToggleRowDetailsAsync(TGridItem item) + { + var key = ItemKey(item); + if (!_expandedRowDetails.Remove(key)) + { + _expandedRowDetails.Add(key); + } + + if (OnRowDetailsToggle.HasDelegate) + { + await OnRowDetailsToggle.InvokeAsync(item); + } + + StateHasChanged(); + } + + /// + /// Expands the content of the specified . + /// + /// The item that holds the row's values. + /// A representing the completion of the operation. + public Task ExpandRowDetailsAsync(TGridItem item) + => IsRowDetailsExpanded(item) ? Task.CompletedTask : ToggleRowDetailsAsync(item); + + /// + /// Collapses the content of the specified . + /// + /// The item that holds the row's values. + /// A representing the completion of the operation. + public Task CollapseRowDetailsAsync(TGridItem item) + => IsRowDetailsExpanded(item) ? ToggleRowDetailsAsync(item) : Task.CompletedTask; + + /// + /// Expands the content of all currently loaded rows. + /// Raises for each row that was not already expanded. + /// + /// A representing the completion of the operation. + public async Task ExpandAllRowDetailsAsync() + { + foreach (var item in _internalGridContext.Items) + { + if (_expandedRowDetails.Add(ItemKey(item)) && OnRowDetailsToggle.HasDelegate) + { + await OnRowDetailsToggle.InvokeAsync(item); + } + } + + StateHasChanged(); + } + + /// + /// Collapses the content of all rows. + /// Raises for each currently loaded row that was expanded. + /// + /// A representing the completion of the operation. + public async Task CollapseAllRowDetailsAsync() + { + if (OnRowDetailsToggle.HasDelegate) + { + foreach (var item in _internalGridContext.Items) + { + if (_expandedRowDetails.Contains(ItemKey(item))) + { + await OnRowDetailsToggle.InvokeAsync(item); + } + } + } + + _expandedRowDetails.Clear(); + StateHasChanged(); + } + + // Distinct @key for the extra details row rendered below the master row + private object RowDetailsKey(TGridItem item) => (ItemKey(item), nameof(RowDetails)); } diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.css b/src/Core/Components/DataGrid/FluentDataGrid.razor.css index 268d4e553a..61d7a76990 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.css +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.css @@ -21,7 +21,10 @@ display: contents; } - .fluent-data-grid.grid tr { + /* Restricted to direct thead/tbody children so a nested FluentDataGrid (e.g. in RowDetails) + doesn't have its own rows absorbed into this grid's CSS Grid layout. */ + .fluent-data-grid.grid > thead > tr, + .fluent-data-grid.grid > tbody > tr { display: contents; } diff --git a/src/Core/Components/DataGrid/FluentDataGridCell.razor b/src/Core/Components/DataGrid/FluentDataGridCell.razor index 85b0859883..5800fb6868 100644 --- a/src/Core/Components/DataGrid/FluentDataGridCell.razor +++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor @@ -2,7 +2,7 @@ @inherits FluentComponentBase @typeparam TGridItem -@if (CellType == DataGridCellType.Default) +@if (CellType is DataGridCellType.Default or DataGridCellType.RowDetails) { protected string? StyleValue => DefaultStyleBuilder - .AddStyle("grid-column", GridColumn.ToString(CultureInfo.InvariantCulture), () => !Grid.EffectiveLoadingValue && (Grid.Items is not null || Grid.ItemsProvider is not null) && InternalGridContext.TotalItemCount > 0 && Grid.DisplayMode == DataGridDisplayMode.Grid) + .AddStyle("grid-column", GridColumn.ToString(CultureInfo.InvariantCulture), () => CellType != DataGridCellType.RowDetails && !Grid.EffectiveLoadingValue && (Grid.Items is not null || Grid.ItemsProvider is not null) && InternalGridContext.TotalItemCount > 0 && Grid.DisplayMode == DataGridDisplayMode.Grid) .AddStyle("text-align", "center", Column is SelectColumn) .AddStyle("align-content", "center", Column is SelectColumn) .AddStyle("min-width", Column?.MinWidth, Owner.RowType is DataGridRowType.Header or DataGridRowType.StickyHeader && (Grid.HeaderCellAsButtonWithMenu || Column?.Pin != DataGridColumnPin.None)) @@ -46,7 +46,7 @@ public FluentDataGridCell(LibraryConfiguration configuration) : base(configurati .AddStyle("padding-top", "6px", Column is SelectColumn && Grid.RowSize == DataGridRowSize.Small && Owner.RowType == DataGridRowType.Default) .AddStyle("width", Column?.Width, !string.IsNullOrEmpty(Column?.Width) && Grid.DisplayMode == DataGridDisplayMode.Table) .AddStyle("height", $"{Grid.ItemSize.ToString(CultureInfo.InvariantCulture):0}px", () => !Grid.EffectiveLoadingValue && Grid.Virtualize) - .AddStyle("height", $"{((int)Grid.RowSize).ToString(CultureInfo.InvariantCulture)}px", () => !Grid.EffectiveLoadingValue && !Grid.Virtualize && !Grid.MultiLine && (Grid.Items is not null || Grid.ItemsProvider is not null) && InternalGridContext.TotalItemCount > 0) + .AddStyle("height", $"{((int)Grid.RowSize).ToString(CultureInfo.InvariantCulture)}px", () => CellType != DataGridCellType.RowDetails && !Grid.EffectiveLoadingValue && !Grid.Virtualize && !Grid.MultiLine && (Grid.Items is not null || Grid.ItemsProvider is not null) && InternalGridContext.TotalItemCount > 0) .AddStyle("height", "100%", Grid.MultiLine) .AddStyle("min-height", "40px", Owner.RowType != DataGridRowType.Default) .AddStyle("position", "sticky", Column != null && Column.Pin != DataGridColumnPin.None) diff --git a/src/Core/Components/DataGrid/FluentDataGridCell.razor.css b/src/Core/Components/DataGrid/FluentDataGridCell.razor.css index 02ab96466f..ed4fa6a220 100644 --- a/src/Core/Components/DataGrid/FluentDataGridCell.razor.css +++ b/src/Core/Components/DataGrid/FluentDataGridCell.razor.css @@ -171,3 +171,16 @@ min-height: 20px; vertical-align: middle; } + +.fluent-data-grid td.row-details-cell { + white-space: normal; + overflow: visible; + text-overflow: unset; +} + +/* Unlike the HierarchicalToggle container (which wraps the whole cell content), + this toggle is a standalone element placed before the cell's own content, + so it must stay inline instead of starting its own block. */ +.fluent-data-grid .row-details-toggle-container { + display: inline-flex; +} diff --git a/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs b/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs index 1a08cb9966..4f0d3248ea 100644 --- a/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs +++ b/src/Core/Components/DataGrid/FluentDataGridRow.razor.cs @@ -149,7 +149,8 @@ internal async Task HandleOnRowClickAsync(string rowId) if (!string.IsNullOrWhiteSpace(row.Class) && (row.Class.Contains(FluentDataGrid.EMPTY_CONTENT_ROW_CLASS, StringComparison.Ordinal) || - row.Class.Contains(FluentDataGrid.LOADING_CONTENT_ROW_CLASS, StringComparison.Ordinal))) + row.Class.Contains(FluentDataGrid.LOADING_CONTENT_ROW_CLASS, StringComparison.Ordinal) || + row.Class.Contains(FluentDataGrid.ROW_DETAILS_ROW_CLASS, StringComparison.Ordinal))) { return; } @@ -177,6 +178,11 @@ internal async Task HandleOnRowDoubleClickAsync(string rowId) return; } + if (!string.IsNullOrWhiteSpace(row.Class) && row.Class.Contains(FluentDataGrid.ROW_DETAILS_ROW_CLASS, StringComparison.Ordinal)) + { + return; + } + if (Grid.OnRowDoubleClick.HasDelegate) { await Grid.OnRowDoubleClick.InvokeAsync(row); diff --git a/src/Core/Components/DataGrid/FluentDataGridRow.razor.css b/src/Core/Components/DataGrid/FluentDataGridRow.razor.css index 3534d166d5..082888cf2b 100644 --- a/src/Core/Components/DataGrid/FluentDataGridRow.razor.css +++ b/src/Core/Components/DataGrid/FluentDataGridRow.razor.css @@ -4,7 +4,8 @@ .fluent-data-grid .hover:not([row-type='header'], .fluent-data-grid [row-type='sticky-header'], -.fluent-data-grid .loading-content-row):hover td:not(.empty-content-cell) { +.fluent-data-grid .loading-content-row, +.fluent-data-grid .row-details-row):hover td:not(.empty-content-cell) { cursor: pointer; background-color: var(--datagrid-hover-color, var(--colorNeutralStroke2)); } diff --git a/src/Core/Enums/DataGridCellType.cs b/src/Core/Enums/DataGridCellType.cs index 05bb5deb86..d9c2263e0f 100644 --- a/src/Core/Enums/DataGridCellType.cs +++ b/src/Core/Enums/DataGridCellType.cs @@ -27,4 +27,10 @@ public enum DataGridCellType /// [Description("rowheader")] RowHeader, + + /// + /// A cell that spans all columns and holds the expanded content of a row. + /// + [Description("rowdetails")] + RowDetails, } diff --git a/src/Core/Localization/LanguageResource.resx b/src/Core/Localization/LanguageResource.resx index 848ee1fe40..e206790120 100644 --- a/src/Core/Localization/LanguageResource.resx +++ b/src/Core/Localization/LanguageResource.resx @@ -363,6 +363,9 @@ Toggle nesting + + Toggle row details + {0} item diff --git a/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor b/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor new file mode 100644 index 0000000000..afc469ba3d --- /dev/null +++ b/tests/Core/Components/DataGrid/FluentDataGridRowDetailsTests.razor @@ -0,0 +1,209 @@ +@using Bunit.TestDoubles +@using Xunit +@inherits FluentUITestContext + +@code { + public FluentDataGridRowDetailsTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + + // Register services + Services.AddFluentUIComponents(); + Services.AddScoped(factory => new KeyCodeService()); + } + + [Fact] + public void FluentDataGrid_RowDetails_Renders_Toggle_And_Starts_Collapsed() + { + // Arrange && Act + var cut = RenderGrid(); + + // Assert + Assert.Equal(3, cut.FindAll(".row-details-toggle-container").Count); + Assert.Empty(cut.FindAll("tr.row-details-row")); + } + + [Fact] + public void FluentDataGrid_RowDetails_Toggle_Expands_And_Collapses() + { + // Arrange + var cut = RenderGrid(); + + // Act: expand the first row + cut.FindAll(".hierarchical-expand-button")[0].Click(); + + // Assert + var detailsRow = cut.Find("tr.row-details-row"); + Assert.Contains("details of Denis Voituron", detailsRow.TextContent); + + // Act: collapse it again + cut.FindAll(".hierarchical-expand-button")[0].Click(); + + // Assert + Assert.Empty(cut.FindAll("tr.row-details-row")); + } + + [Fact] + public async Task FluentDataGrid_RowDetails_ExpandAll_And_CollapseAllAsync() + { + // Arrange + var cut = RenderGrid(); + + // Act + await cut.InvokeAsync(() => cut.Instance.ExpandAllRowDetailsAsync()); + + // Assert + Assert.Equal(3, cut.FindAll("tr.row-details-row").Count); + + // Act + await cut.InvokeAsync(() => cut.Instance.CollapseAllRowDetailsAsync()); + + // Assert + Assert.Empty(cut.FindAll("tr.row-details-row")); + } + + [Fact] + public async Task FluentDataGrid_RowDetails_Expand_And_Collapse_Single_ItemAsync() + { + // Arrange + var cut = RenderGrid(); + var item = GetCustomers().First(); + + // Act && Assert + await cut.InvokeAsync(() => cut.Instance.ExpandRowDetailsAsync(item)); + Assert.True(cut.Instance.IsRowDetailsExpanded(item)); + + // Expanding twice is a no-op + await cut.InvokeAsync(() => cut.Instance.ExpandRowDetailsAsync(item)); + Assert.True(cut.Instance.IsRowDetailsExpanded(item)); + + await cut.InvokeAsync(() => cut.Instance.CollapseRowDetailsAsync(item)); + Assert.False(cut.Instance.IsRowDetailsExpanded(item)); + } + + [Fact] + public void FluentDataGrid_RowDetails_Toggle_Raises_OnRowDetailsToggle() + { + // Arrange + Customer? toggledItem = null; + var cut = Render>( + @ + + + + +

details of @context.Name

+
+
); + + // Act + cut.FindAll(".hierarchical-expand-button")[1].Click(); + + // Assert + Assert.Equal("Vincent Baaij", toggledItem?.Name); + } + + [Fact] + public async Task FluentDataGrid_RowDetails_ExpandAll_Raises_OnRowDetailsToggle_For_Each_Changed_RowAsync() + { + // Arrange + var toggled = new List(); + var cut = Render>( + @ + + + + +

details of @context.Name

+
+
); + + // Act: expand one row manually first, then expand all + await cut.InvokeAsync(() => cut.Instance.ExpandRowDetailsAsync(GetCustomers().First())); + toggled.Clear(); + await cut.InvokeAsync(() => cut.Instance.ExpandAllRowDetailsAsync()); + + // Assert: only the two rows that actually changed state raised the callback + Assert.Equal(2, toggled.Count); + Assert.DoesNotContain(toggled, c => c.Name == "Denis Voituron"); + + // Act: expanding all again is a no-op, nothing should be raised + toggled.Clear(); + await cut.InvokeAsync(() => cut.Instance.ExpandAllRowDetailsAsync()); + Assert.Empty(toggled); + } + + [Fact] + public async Task FluentDataGrid_RowDetails_CollapseAll_Raises_OnRowDetailsToggle_For_Each_Expanded_RowAsync() + { + // Arrange + var toggled = new List(); + var cut = Render>( + @ + + + + +

details of @context.Name

+
+
); + await cut.InvokeAsync(() => cut.Instance.ExpandRowDetailsAsync(GetCustomers().First())); + toggled.Clear(); + + // Act + await cut.InvokeAsync(() => cut.Instance.CollapseAllRowDetailsAsync()); + + // Assert: only the one row that was actually expanded raised the callback + Assert.Single(toggled); + Assert.Equal("Denis Voituron", toggled[0].Name); + } + + [Fact] + public void FluentDataGrid_RowDetails_Toggle_Has_Dedicated_AriaLabel() + { + // Arrange && Act + var cut = RenderGrid(); + + // Assert: the toggle button must not reuse the hierarchical-view's "Toggle nesting" label + var button = cut.FindAll(".row-details-toggle-container button, .row-details-toggle-container fluent-button")[0]; + Assert.Equal("Toggle row details", button.GetAttribute("aria-label")); + } + + [Fact] + public void FluentDataGrid_RowDetails_With_Virtualize_Throws() + { + // Arrange && Act && Assert + Assert.Throws(() => Render>( + @ + + + + +

details of @context.Name

+
+
)); + } + + private IRenderedComponent> RenderGrid() + { + return Render>( + @ + + + + +

details of @context.Name

+
+
); + } + + // Sample data... + private IEnumerable GetCustomers() + { + yield return new Customer(1, "Denis Voituron"); + yield return new Customer(2, "Vincent Baaij"); + yield return new Customer(3, "Bill Gates"); + } + + private record Customer(int Id, string Name); +}