From 6bbbb4a93e002714fd09ec9ffc7162c15a1b1125 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sat, 7 Nov 2026 00:34:49 +0800 Subject: [PATCH] Add dashboard resource-selector dropdown (#21861) ## Description Make resource switching available while the dashboard sidebar is collapsed, without reopening the full sidebar. This extracts only the resource-selector dropdown from #21793. ### User-facing usage Click the resource-name button to open the searchable resource list. Normal selection closes it; Ctrl/Cmd/Shift selection keeps it open. Selecting all closes it. Mobile retains the existing drawer. ### Validation - Passed: **1,394 component tests** (before the final CSS-only adjustments): ```powershell dotnet test --project tests\Aspire.Dashboard.Components.Tests\Aspire.Dashboard.Components.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" ``` - Passed after the styling changes: ```powershell aspire resource aspire-dashboard rebuild --apphost playground\TestShop\TestShop.AppHost\TestShop.AppHost.csproj --non-interactive ``` - Browser-verified popup placement, normal/modifier selection, Select all, dismissal, and button styling. ### Screenshots / Recordings > **This PR includes UI changes.** Please add screenshots or screen recordings so reviewers can evaluate the visual changes without running locally. > > - For before/after comparisons, place them side-by-side or label them clearly. > - For interactive changes (animations, transitions, new flows), prefer a short screen recording (GIF or video). > - If you cannot capture visuals now, note what scenario to test and mark this section as TODO. ![Resource selector dropdown](https://github.com/user-attachments/assets/d5b18733-0a7a-4b1f-bb0f-9e63a1744894) Fixes # (issue) ## Checklist - Is this feature complete? - [x] Yes. Ready to ship. - [ ] No. Follow-up changes expected. - Are you including unit tests for the changes and scenario tests if relevant? - [x] Yes - [ ] No - Did you add public API? - [ ] Yes - If yes, did you have an API Review for it? - [ ] Yes - [ ] No - Did you add `` and `` elements on your triple slash comments? - [ ] Yes - [ ] No - [x] No - Does the change make any security assumptions or guarantees? - [ ] Yes - If yes, have you done a threat model and had a security review? - [ ] Yes - [ ] No - [x] No --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 52afb3acb7ad2af5529804361e9b8ffcd1b9d3d3) --- .../Components/Layout/ResourcesLayout.razor | 217 ++++++++----- .../Layout/ResourcesLayout.razor.cs | 71 ++++- .../Layout/ResourcesLayout.razor.css | 74 ++++- .../Layout/ResourcesLayout.razor.js | 65 +++- src/Aspire.Dashboard/README.md | 6 + .../Layout/ResourcesLayoutTests.cs | 292 +++++++++++++++++- .../Shared/ResourceSetupHelpers.cs | 2 + .../Playwright/ResourcePaneMotionTests.cs | 26 +- .../Playwright/ResourcePaneResponsiveTests.cs | 62 ++++ 9 files changed, 711 insertions(+), 104 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor index 573e562d1c9..cf58e236c98 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor @@ -25,46 +25,7 @@ var isDesktop = ViewportInformation.IsDesktop; var showCollapsedPane = isDesktop && _isPaneCollapsed && !_isPaneFullCanvas; -
- @* A single header keeps section subscriptions stable across viewport changes. *@ -
- @if (isDesktop) - { - - @((RenderFragment)(builder => RenderTabNavigation(builder, isDesktop: true))) - - } - else - { -
- @((RenderFragment)(builder => RenderTabNavigation(builder, isDesktop: false))) -
- } - @if (!_isPaneFullCanvas && (isDesktop || !_isDrawerOpen)) - { - RenderHeaderRow(__builder, isDesktop); -
-
- -
- @if (isDesktop) - { -
- -
- } - @if (isDesktop && DetailParentUrl is { } parentUrl) - { - - } -
- } -
- ; + +
+ @* A single header keeps section subscriptions stable across viewport changes. *@ +
+ @if (isDesktop) + { + + @((RenderFragment)(builder => RenderTabNavigation(builder, isDesktop: true))) + + } + else + { +
+ @((RenderFragment)(builder => RenderTabNavigation(builder, isDesktop: false))) +
+ } + @if (!_isPaneFullCanvas && (isDesktop || !_isDrawerOpen)) + { + RenderHeaderRow(__builder, isDesktop); +
+
+ +
+ @if (isDesktop) + { +
+ +
+ } + @if (isDesktop && DetailParentUrl is { } parentUrl) + { + + } +
+ } +
+ @if (showCollapsedPane) + { + + } + else + { + @resourcePane + } @* Keep the page mounted during expansion; navigation stays outside the hidden content region. *@
$"resource-row-action resource-row-action-priority-{Math.Min(actionIndex, 3)}"; - // On desktop the title is a heading. On mobile it also opens the resource list, which is a full-screen sheet, so it - // is drawn as a select, with the list icon and a chevron, to show that it can be changed. The button is inside the - // heading rather than replacing it, so the page keeps its heading. A raw button gives the start-aligned - // layout that a FluentButton can't without styling its shadow parts. + // Keep the heading when its title opens the resource list: a popover on collapsed desktop layouts, or a full-screen + // sheet on mobile. The mobile picker uses a raw button for its full-width, start-aligned layout. private void RenderTitle(RenderTreeBuilder __builder, string text, bool isDesktop) { - if (isDesktop) + if (isDesktop && !_isPaneCollapsed) {

@text

+ } + else if (isDesktop) + { +

+ + @((RenderFragment)RenderHeaderIcon) + @text + +

} else { @@ -701,6 +734,60 @@ } } + private void RenderHeaderIcon(RenderTreeBuilder __builder) + { + if (CurrentTab == ResourceTab.Parameters) + { + + + + } + else if (SelectedResource is { } selected) + { + + + + + } + else if (SelectedTelemetryOnlyResource is { } telemetryOnly) + { + + + + } + else if (IsMultiSelection) + { + + @if (_selectedItems.All(item => item.Resource is { IsParameter: true })) + { + + } + else + { + + } + + } + else if (SelectedResourceName is not null && _isLoaded) + { + + + + } + else + { + + + + } + } + private void RenderHeaderRow(RenderTreeBuilder __builder, bool isDesktop) {
@@ -715,11 +802,12 @@ aria-label="@Loc[_isPaneCollapsed && !_isPaneFullCanvas ? nameof(Dashboard.Resources.Layout.ResourcePaneExpand) : nameof(Dashboard.Resources.Layout.ResourcePaneCollapse)]" aria-expanded="@((!_isPaneCollapsed || _isPaneFullCanvas).ToString().ToLowerInvariant())" /> } + @if (!isDesktop || !_isPaneCollapsed) + { + RenderHeaderIcon(__builder); + } @if (CurrentTab == ResourceTab.Parameters) { - - -
@{ RenderTitle(__builder, Loc[nameof(Dashboard.Resources.Layout.NavMenuParametersTab)], isDesktop); } @@ -729,13 +817,6 @@ else if (SelectedResource is { } selected) { var selectedName = GetResourceName(selected); - - - -
@{ RenderTitle(__builder, selectedName, isDesktop); } @@ -744,12 +825,6 @@ } else if (SelectedTelemetryOnlyResource is { } telemetryOnly) { - - -
@{ RenderTitle(__builder, telemetryOnly.Name, isDesktop); } @@ -759,16 +834,6 @@ else if (IsMultiSelection) { var selectedItemsAreParameters = _selectedItems.All(item => item.Resource is { IsParameter: true }); - - @if (selectedItemsAreParameters) - { - - } - else - { - - } -
@{ @@ -785,9 +850,6 @@ } else if (SelectedResourceName is not null && _isLoaded) { - - -
@{ RenderTitle(__builder, SelectedResourceName, isDesktop); } @@ -797,9 +859,6 @@ } else { - - -
@{ RenderTitle(__builder, GetScopeTitle(), isDesktop); } diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs index 02ba6319a0a..8dbf82c76e2 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs @@ -36,6 +36,7 @@ public sealed partial class ResourcesLayout : LayoutComponentBase, IAsyncDisposa { private const string FilterButtonId = "resourcePaneFilterButton"; private const string GroupingButtonId = "resourcePaneGroupingButton"; + private const string ResourcePickerButtonId = "resourcePanePickerButton"; internal const int MinimumPaneWidthPx = 420; internal const int MaximumPaneWidthPx = 560; internal const int DefaultPaneWidthPx = MinimumPaneWidthPx; @@ -74,6 +75,11 @@ public sealed partial class ResourcesLayout : LayoutComponentBase, IAsyncDisposa private string? _focusPaneSelectionReveal; private AspireMenu? _resourceContextMenu; private IReadOnlyList _resourceContextMenuItems = []; + private bool _isResourcePickerOpen; + private string? _resourcePickerSelectionLocation; + private bool _wasResourcePickerOpen; + private bool _focusResourcePicker; + private FluentTextInput? _resourcePaneSearch; private bool _showTelemetryOnlyResources; // Which kinds of items the pane lists, and so what "all resources" covers. Telemetry sources use // _showTelemetryOnlyResources, which replaced the filter menu's telemetry-only option. @@ -435,6 +441,10 @@ private void UpsertResource(ResourceViewModel resource) protected override async Task OnParametersSetAsync() { + if (!ViewportInformation.IsDesktop) + { + _isResourcePickerOpen = false; + } if (!ViewportInformation.IsDesktop && (_isPaneFullCanvas || _isPaneSelectionReveal)) { _isPaneFullCanvas = false; @@ -487,6 +497,9 @@ protected override async Task OnParametersSetAsync() SetResourceListVisible(true); } _isDrawerOpen = !ViewportInformation.IsDesktop && showResourceList; + // Modifier selection changes the URL without dismissing the picker. Other navigation still closes it. + _isResourcePickerOpen = _isResourcePickerOpen && string.Equals(location, _resourcePickerSelectionLocation, StringComparison.Ordinal); + _resourcePickerSelectionLocation = null; if (previousTab != CurrentTab || !previousSelection.SequenceEqual(_selectedResourceNames, StringComparers.ResourceName)) { // Reused pages must replace subscriptions and terminal state before an @@ -505,9 +518,13 @@ protected override async Task OnParametersSetAsync() protected override async Task OnAfterRenderAsync(bool firstRender) { + var focusResourcePicker = _focusResourcePicker && _isResourcePickerOpen; + _focusResourcePicker = false; + var pickerChanged = _isResourcePickerOpen != _wasResourcePickerOpen; var showResizer = ViewportInformation.IsDesktop && !_isPaneCollapsed && !_isPaneFullCanvas; var registrationChanged = showResizer != _isResizerRegistered || !_isOrderingRegistered; - if (_usesCommandSelectionModifier is not null && !registrationChanged && _isTabOverflowRegistered && + if (_usesCommandSelectionModifier is not null && !pickerChanged && !focusResourcePicker && + !registrationChanged && _isTabOverflowRegistered && _startPaneSelectionReveal is null && _focusPaneSelectionReveal is null) { return; @@ -522,6 +539,15 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _usesCommandSelectionModifier = await _jsModule.InvokeAsync("usesCommandSelectionModifier"); } _selfRef ??= DotNetObjectReference.Create(this); + if (pickerChanged) + { + await _jsModule.InvokeVoidAsync("setResourcePickerOpen", _layoutElement, _selfRef, _isResourcePickerOpen); + _wasResourcePickerOpen = _isResourcePickerOpen; + } + if (focusResourcePicker && _resourcePaneSearch is { } search) + { + await search.Element.FocusAsync(); + } if (!_isTabOverflowRegistered) { _isTabOverflowRegistered = true; @@ -1562,6 +1588,7 @@ private async Task PersistCollapsedResourceNamesAsync() private async Task TogglePaneCollapsedAsync() { + _isResourcePickerOpen = false; CancelPaneSelectionReveal(); _animatedPaneNavigation = null; _animatePaneTransition = true; @@ -1575,6 +1602,24 @@ private async Task TogglePaneCollapsedAsync() private void CloseDrawer() => _isDrawerOpen = false; + private void ToggleResourcePicker() + { + _isResourcePickerOpen = !_isResourcePickerOpen; + _focusResourcePicker = _isResourcePickerOpen; + } + + [JSInvokable] + public Task OnResourcePickerClosedAsync() => InvokeAsync(() => + { + if (_isDisposed || !_isResourcePickerOpen) + { + return; + } + + _isResourcePickerOpen = false; + StateHasChanged(); + }); + private void UpdatePaneMenuItems() { _paneMenuItems.Clear(); @@ -2297,6 +2342,7 @@ private string GetSelectionUrl(IReadOnlyList resourceNames) /// private async Task OnRowClickAsync(MouseEventArgs e, string resourceName) { + _isResourcePickerOpen &= e.ShiftKey || e.CtrlKey || e.MetaKey; // The selection can also change by navigating, for example with a link to a resource. Ranges then start at // the last selected resource. var anchor = _selectionAnchor is not null && IsSelected(_selectionAnchor) ? _selectionAnchor : _selectedResourceNames is [.., var last] ? last : null; @@ -2348,11 +2394,7 @@ private async Task OnRowClickAsync(MouseEventArgs e, string resourceName) await SetStoredSelectionAsync(selection); } - var url = GetSelectionUrl(selection); - _animatedPaneNavigation = _isPaneFullCanvas && selection.Count == 1 - ? NavigationManager.ToAbsoluteUri(url).AbsoluteUri - : null; - NavigationManager.NavigateTo(url); + NavigateToSelection(selection); } // A single selected resource is shown by its row alone, so a one-resource group doesn't duplicate the highlight. @@ -2363,6 +2405,7 @@ private bool IsResourceGroupSelected(List groupResourceNames) => private async Task OnResourceGroupClick(MouseEventArgs e, List groupResourceNames) { + _isResourcePickerOpen &= e.ShiftKey || e.CtrlKey || e.MetaKey; if (groupResourceNames.Count == 0) { return; @@ -2396,11 +2439,12 @@ private async Task OnResourceGroupClick(MouseEventArgs e, List groupReso } _selectionAnchor = selection is [.., var last] ? last : null; - NavigationManager.NavigateTo(GetSelectionUrl(selection)); + NavigateToSelection(selection); } private async Task OnAllResourcesClickAsync() { + _isResourcePickerOpen = false; var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); var path = NavigationManager.ToBaseRelativePath(uri.GetLeftPart(UriPartial.Path)); var query = HttpUtility.ParseQueryString(uri.Query); @@ -2451,7 +2495,17 @@ private async Task ClearSelectionAsync() { _selectionAnchor = null; await SetStoredSelectionAsync([]); - NavigationManager.NavigateTo(GetSelectionUrl([])); + NavigateToSelection([]); + } + + private void NavigateToSelection(IReadOnlyList resourceNames) + { + var url = GetSelectionUrl(resourceNames); + _animatedPaneNavigation = _isPaneFullCanvas && resourceNames.Count == 1 + ? NavigationManager.ToAbsoluteUri(url).AbsoluteUri + : null; + _resourcePickerSelectionLocation = _isResourcePickerOpen ? NavigationManager.ToAbsoluteUri(url).AbsoluteUri : null; + NavigationManager.NavigateTo(url); } /// @@ -2691,6 +2745,7 @@ public async ValueTask DisposeAsync() await module.InvokeVoidAsync("unregisterResourceOrdering", _layoutElement); await module.InvokeVoidAsync("unregisterResourceContextMenu", _layoutElement); await module.InvokeVoidAsync("unregisterPaneActivityVisibility", _layoutElement); + await module.InvokeVoidAsync("unregisterResourcePicker", _layoutElement); await module.InvokeVoidAsync("cancelPaneSelectionReveal", _layoutElement); await module.InvokeVoidAsync("unregisterTabOverflow", _tabOverflowRegistrationId); } diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css index 34b316ae488..b8f32aa8812 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css @@ -59,8 +59,7 @@ --resource-insight-failure-width: 40px; } -/* Keep the pane mounted while its grid track closes. Visibility waits for motion to - finish; inert and pointer-events exclude it from interaction immediately. */ +/* The collapsed sidebar track is hidden; the popup overrides this for its own pane. */ .resources-layout.pane-collapsed .resource-pane { visibility: hidden; pointer-events: none; @@ -85,6 +84,75 @@ display: none; } +.resource-picker-popover { + position: fixed; + position-anchor: --resource-picker; + top: anchor(bottom); + left: anchor(left); + right: auto; + bottom: 12px; + margin: 6px 0 0; + position-try-fallbacks: flip-inline; + box-sizing: border-box; + width: min(var(--resource-pane-expanded-width, 290px), calc(100vw - 24px)); + max-width: none; + height: max-content; + max-height: stretch; + padding: 0; + border: 1px solid var(--colorNeutralStroke1); + border-radius: var(--borderRadiusMedium); + background: var(--aspire-page-background); + color: var(--colorNeutralForeground1); + box-shadow: var(--shadow16); + overflow: hidden; +} + +.resource-picker-popover:popover-open { + display: flex; + flex-direction: column; +} + +.resource-title:has(.resource-picker-button) { + overflow: visible; +} + +.resource-title ::deep .resource-picker-button { + max-width: 100%; + font: inherit; + font-size: var(--fontSizeBase400); +} + +.resource-picker-button > [slot="start"] { + display: flex; + align-items: center; +} + +.resource-picker-button .resource-header-icon { + flex: 0 0 20px; + width: 20px; + height: 20px; + border: 0; + background: transparent; +} + +.resource-picker-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resources-layout.pane-collapsed .resource-pane-popup { + visibility: visible; + pointer-events: auto; +} + +.resource-pane.resource-pane-popup { + flex: 0 1 auto; + width: 100%; + max-height: min(600px, calc(100dvh - 120px)); + border-right: 0; +} + .resources-layout.pane-full-canvas .resource-content-foot { display: none; } @@ -1171,7 +1239,7 @@ /* Preserve row geometry while the containing track shrinks; only the clip boundary moves. Full canvas still discloses metadata at its actual width. */ - .resources-layout:not(.pane-full-canvas) .resource-pane-contents > * { + .resources-layout:not(.pane-full-canvas) .resource-pane:not(.resource-pane-popup) .resource-pane-contents > * { width: calc(var(--resource-pane-width) - 1px); } diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js index 2ab417040a7..65ce9071704 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js @@ -17,7 +17,6 @@ export function usesCommandSelectionModifier() { export function registerPaneActivityVisibility(layoutElement, dotNetRef) { unregisterPaneActivityVisibility(layoutElement); - const pane = layoutElement.querySelector(':scope > .resource-pane'); let frame = 0; let reported; let forceRefresh = false; @@ -37,8 +36,9 @@ export function registerPaneActivityVisibility(layoutElement, dotNetRef) { } frame = requestAnimationFrame(() => { frame = 0; + const pane = layoutElement.querySelector(':scope > .resource-pane'); // Match the activity disclosure boundary, not the saved compact sidebar width. - const visible = document.visibilityState === 'visible' && + const visible = pane !== null && document.visibilityState === 'visible' && layoutElement.classList.contains('pane-full-canvas') && pane.getBoundingClientRect().width >= 1440; const force = forceRefresh; @@ -67,9 +67,28 @@ export function registerPaneActivityVisibility(layoutElement, dotNetRef) { }; const reconnectModal = document.getElementById('components-reconnect-modal'); const resize = new ResizeObserver(() => schedule()); - const presentation = new MutationObserver(() => schedule()); - resize.observe(pane); - presentation.observe(layoutElement, { attributes: true, attributeFilter: ['class'] }); + let observedPane = null; + const observePane = () => { + const pane = layoutElement.querySelector(':scope > .resource-pane'); + if (pane !== observedPane) { + if (observedPane) { + resize.unobserve(observedPane); + } + observedPane = pane; + if (pane) { + resize.observe(pane); + } + } + }; + const presentation = new MutationObserver(() => { + observePane(); + schedule(); + }); + // Grid-track animations resize the pane without resizing its workspace. + // Collapse can remove the pane, so rebind its observation when it is remounted. + resize.observe(layoutElement); + observePane(); + presentation.observe(layoutElement, { attributes: true, attributeFilter: ['class'], childList: true }); document.addEventListener('visibilitychange', resume); window.addEventListener('pageshow', resume); window.addEventListener('focus', resume); @@ -91,6 +110,42 @@ export function unregisterPaneActivityVisibility(layoutElement) { activityRegistrations.delete(layoutElement); } +const resourcePickers = new WeakMap(); + +export function setResourcePickerOpen(layoutElement, dotNetRef, open) { + const picker = layoutElement.querySelector(':scope > .resource-picker-popover'); + let registration = resourcePickers.get(layoutElement); + if (registration?.picker !== picker) { + unregisterResourcePicker(layoutElement); + if (!picker) { + return; + } + + const onToggle = event => { + // Only the native popover's own toggle closes the picker. Nested controls can emit bubbling toggle events. + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/toggle_event + if (event.target === picker && event.newState === "closed") { + dotNetRef.invokeMethodAsync("OnResourcePickerClosedAsync"); + } + }; + picker.addEventListener("toggle", onToggle); + registration = { picker, onToggle }; + resourcePickers.set(layoutElement, registration); + } + + if (picker.matches(":popover-open") !== open) { + picker.togglePopover(open); + } +} + +export function unregisterResourcePicker(layoutElement) { + const registration = resourcePickers.get(layoutElement); + if (registration) { + registration.picker.removeEventListener("toggle", registration.onToggle); + resourcePickers.delete(layoutElement); + } +} + // Priority+ overflow for the tab strip in the header. Tabs that no longer fit are marked with data-tab-overflow (hidden // by CSS) from the right, and their ids are reported to .NET, which lists them in the "..." menu. Hiding is done in the // DOM, rather than by rendering, so resizing never waits for a server round trip. diff --git a/src/Aspire.Dashboard/README.md b/src/Aspire.Dashboard/README.md index d3c5344eeb9..705c66361ac 100644 --- a/src/Aspire.Dashboard/README.md +++ b/src/Aspire.Dashboard/README.md @@ -8,6 +8,12 @@ The dashboard shows: - Live console logs of resources. - Live telemetry, such as structured logs, traces and metrics. +When the resource list is collapsed, click the resource name or **All resources** in the toolbar to open +the list below it. Selecting a resource closes the popup without expanding the sidebar. +Shift-click and Ctrl-click (Cmd-click on macOS) keep the popup open for multi-selection, including on resource groups. +Selecting **Select all** (or **All sources** in a standalone dashboard) always closes the popup. +The popup includes search; filter and grouping buttons are available only in the sidebar or mobile drawer. + ## Grouping resources Use **Group resources**, beside **Filters** in the resources pane, to group diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs index 8deaddd83c4..c896991bd5e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs @@ -29,6 +29,286 @@ namespace Aspire.Dashboard.Components.Tests.Layout; [UseCulture("en-US")] public class ResourcesLayoutTests : DashboardTestContext { + [Theory] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, false)] + [InlineData(false, true)] + public async Task CollapsedPane_PickerSelectsResourceAndCloses(bool appHostEnabled, bool selected) + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: appHostEnabled, + initialResources: [ModelTestHelpers.CreateResource("api"), ModelTestHelpers.CreateResource("cache")], + resourceChannelProvider: Channel.CreateUnbounded>)); + var storage = Assert.IsType(Services.GetRequiredService()); + storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneCollapsed ? (true, true) : (false, null); + if (!appHostEnabled) + { + await AddTelemetryAsync("api"); + await AddTelemetryAsync("cache"); + } + var navigation = Services.GetRequiredService(); + navigation.NavigateTo(appHostEnabled + ? selected ? DashboardUrls.ResourceOverviewUrl("api") : DashboardUrls.ResourceListUrl() + : DashboardUrls.StructuredLogsUrl(selected ? "api" : null)); + var cut = Render(p => p.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + + var picker = cut.Find("#resourcePanePickerButton"); + Assert.Equal(selected ? "api" : appHostEnabled ? "All resources" : "All sources", picker.TextContent.Trim()); + Assert.Equal("dialog", picker.GetAttribute("aria-haspopup")); + Assert.Equal("false", picker.GetAttribute("aria-expanded")); + var icon = Assert.Single(picker.QuerySelectorAll(".resource-header-icon")); + Assert.NotNull(icon.ParentElement); + Assert.Equal("start", icon.ParentElement.GetAttribute("slot")); + Assert.Empty(cut.FindAll(".resource-header-row > .resource-header-icon")); + Assert.Empty(cut.FindAll(".resource-pane")); + + await picker.ClickAsync(new MouseEventArgs()); + + Assert.Equal("true", cut.Find("#resourcePanePickerButton").GetAttribute("aria-expanded")); + Assert.Single(cut.FindAll(".resource-picker-popover .resource-pane")); + Assert.Equal("auto", cut.Find(".resource-picker-popover").GetAttribute("popover")); + Assert.Empty(cut.FindAll(".resource-pane-resizer")); + Assert.Single(cut.FindAll(".resource-pane-popup .resource-pane-search")); + Assert.Empty(cut.FindAll("#resourcePaneFilterButton")); + Assert.Empty(cut.FindAll("#resourcePaneGroupingButton")); + Assert.False(cut.Find(".resource-pane-popup").HasAttribute("inert")); + Assert.Equal(appHostEnabled ? 3 : 0, cut.FindAll(".resource-kind-toggle").Count); + await cut.Find(".resource-row-link[aria-label='cache']").ClickAsync(new MouseEventArgs()); + Assert.Equal(appHostEnabled ? DashboardUrls.ResourceOverviewUrl("cache") : DashboardUrls.StructuredLogsUrl("cache"), + new Uri(navigation.Uri).PathAndQuery); + Assert.Equal("false", cut.Find("#resourcePanePickerButton").GetAttribute("aria-expanded")); + Assert.Empty(cut.FindAll(".resource-pane")); + + cut.Render(); + Assert.Equal(["cache"], cut.Instance.SelectedResourceNames); + Assert.Equal("cache", cut.Find("#resourcePanePickerButton").TextContent.Trim()); + Assert.Contains("pane-collapsed", cut.Find(".resources-layout").ClassList); + } + + [Theory] + [InlineData(true, "None")] + [InlineData(true, "Control")] + [InlineData(true, "Shift")] + [InlineData(true, "Meta")] + [InlineData(false, "None")] + [InlineData(false, "Control")] + [InlineData(false, "Shift")] + [InlineData(false, "Meta")] + public async Task CollapsedPane_SelectingAllClosesPicker(bool appHostEnabled, string modifier) + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: appHostEnabled, + initialResources: [ModelTestHelpers.CreateResource("api")], + resourceChannelProvider: Channel.CreateUnbounded>)); + var storage = Assert.IsType(Services.GetRequiredService()); + storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneCollapsed ? (true, true) : (false, null); + if (!appHostEnabled) + { + await AddTelemetryAsync("api"); + } + var navigation = Services.GetRequiredService(); + navigation.NavigateTo(appHostEnabled ? DashboardUrls.ResourceOverviewUrl("api") : DashboardUrls.StructuredLogsUrl("api")); + var cut = Render(p => p.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + await cut.Find("#resourcePanePickerButton").ClickAsync(new MouseEventArgs()); + + await cut.Find(appHostEnabled ? ".resource-select-all-toggle" : ".resource-row-all .resource-row-link").ClickAsync(new MouseEventArgs + { + CtrlKey = modifier == "Control", + ShiftKey = modifier == "Shift", + MetaKey = modifier == "Meta" + }); + cut.Render(); + + Assert.Empty(cut.Instance.SelectedResourceNames); + Assert.Equal(appHostEnabled ? DashboardUrls.ResourceListUrl() : DashboardUrls.StructuredLogsUrl(), new Uri(navigation.Uri).PathAndQuery); + Assert.Equal("false", cut.Find("#resourcePanePickerButton").GetAttribute("aria-expanded")); + Assert.Empty(cut.FindAll(".resource-pane")); + } + + [Fact] + public async Task CollapsedPane_PickerDismissesAndExpandingRestoresSidebar() + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true, + initialResources: [ModelTestHelpers.CreateResource("api")], + resourceChannelProvider: Channel.CreateUnbounded>)); + var storage = Assert.IsType(Services.GetRequiredService()); + storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneCollapsed ? (true, true) : (false, null); + Services.GetRequiredService().NavigateTo(DashboardUrls.ResourceOverviewUrl("api")); + var cut = Render(p => p.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + await cut.Find("#resourcePanePickerButton").ClickAsync(new MouseEventArgs()); + await cut.InvokeAsync(cut.Instance.OnResourcePickerClosedAsync); + + Assert.Equal("false", cut.Find("#resourcePanePickerButton").GetAttribute("aria-expanded")); + Assert.Empty(cut.FindAll(".resource-pane")); + Assert.Equal(["api"], cut.Instance.SelectedResourceNames); + await cut.Find("#resourcePanePickerButton").ClickAsync(new MouseEventArgs()); + await cut.Find(".resource-pane-toggle-button").ClickAsync(new MouseEventArgs()); + + Assert.Single(cut.FindAll(".resources-layout > .resource-pane")); + Assert.Empty(cut.FindAll("#resourcePanePickerButton")); + Assert.Single(cut.FindAll(".resource-pane-resizer")); + Assert.Single(cut.FindAll(".resource-header-row > .resource-header-icon")); + Assert.Single(cut.FindAll("#resourcePaneFilterButton")); + Assert.Single(cut.FindAll("#resourcePaneGroupingButton")); + Assert.Equal("api", cut.Find(".resource-title").TextContent.Trim()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MobilePicker_UsesDrawerRegardlessOfDesktopCollapsePreference(bool collapsed) + { + var viewport = new ViewportInformation(IsDesktop: false, IsUltraLowHeight: false, IsUltraLowWidth: false); + ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true, + initialResources: [ModelTestHelpers.CreateResource("api")], + resourceChannelProvider: Channel.CreateUnbounded>)); + var storage = Assert.IsType(Services.GetRequiredService()); + storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneCollapsed ? (true, collapsed) : (false, null); + Services.GetRequiredService().NavigateTo(DashboardUrls.ResourceListUrl()); + var cut = Render(p => p.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + + await cut.Find(".resource-pane-open-button").ClickAsync(new MouseEventArgs()); + + Assert.Contains("drawer-open", cut.Find(".resources-layout").ClassList); + Assert.False(cut.Find(".resource-pane").HasAttribute("hidden")); + Assert.Single(cut.FindAll("#resourcePaneFilterButton")); + Assert.Single(cut.FindAll("#resourcePaneGroupingButton")); + Assert.Empty(cut.FindAll("#resourcePanePickerButton")); + Assert.Empty(cut.FindAll(".resource-picker-popover")); + } + + [Theory] + [InlineData(true, "Control")] + [InlineData(true, "Shift")] + [InlineData(true, "Meta")] + [InlineData(false, "Control")] + [InlineData(false, "Shift")] + [InlineData(false, "Meta")] + public async Task CollapsedPane_ModifierSelectionKeepsPickerOpen(bool appHostEnabled, string modifier) + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: appHostEnabled, + initialResources: [ModelTestHelpers.CreateResource("api"), ModelTestHelpers.CreateResource("cache"), ModelTestHelpers.CreateResource("worker")], + resourceChannelProvider: Channel.CreateUnbounded>)); + var storage = Assert.IsType(Services.GetRequiredService()); + storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneCollapsed ? (true, true) : (false, null); + if (!appHostEnabled) + { + await AddTelemetryAsync("api"); + await AddTelemetryAsync("cache"); + await AddTelemetryAsync("worker"); + } + var navigation = Services.GetRequiredService(); + navigation.NavigateTo(appHostEnabled ? DashboardUrls.ResourceOverviewUrl("api") : DashboardUrls.StructuredLogsUrl("api")); + var cut = Render(p => p.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + await cut.Find("#resourcePanePickerButton").ClickAsync(new MouseEventArgs()); + + await cut.Find(".resource-row-link[aria-label='worker']").ClickAsync(new MouseEventArgs + { + CtrlKey = modifier == "Control", + ShiftKey = modifier == "Shift", + MetaKey = modifier == "Meta" + }); + cut.Render(); + Assert.Equal(modifier == "Shift" ? ["api", "cache", "worker"] : ["api", "worker"], cut.Instance.SelectedResourceNames); + Assert.Equal("true", cut.Find("#resourcePanePickerButton").GetAttribute("aria-expanded")); + Assert.Single(cut.FindAll(".resource-picker-popover .resource-pane")); + + await cut.Find(".resource-row-link[aria-label='cache']").ClickAsync(new MouseEventArgs()); + cut.Render(); + Assert.Equal(["cache"], cut.Instance.SelectedResourceNames); + Assert.Equal("false", cut.Find("#resourcePanePickerButton").GetAttribute("aria-expanded")); + Assert.Empty(cut.FindAll(".resource-pane")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CollapsedPane_ModifierDeselectingLastResourceKeepsPickerOpen(bool appHostEnabled) + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: appHostEnabled, + initialResources: [ModelTestHelpers.CreateResource("api"), ModelTestHelpers.CreateResource("cache")], + resourceChannelProvider: Channel.CreateUnbounded>)); + var storage = Assert.IsType(Services.GetRequiredService()); + storage.OnGetUnprotectedAsync = key => key == BrowserStorageKeys.ResourcePaneCollapsed ? (true, true) : (false, null); + if (!appHostEnabled) + { + await AddTelemetryAsync("api"); + await AddTelemetryAsync("cache"); + } + var navigation = Services.GetRequiredService(); + navigation.NavigateTo(appHostEnabled ? DashboardUrls.ResourceOverviewUrl("api") : DashboardUrls.StructuredLogsUrl("api")); + var cut = Render(p => p.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + await cut.Find("#resourcePanePickerButton").ClickAsync(new MouseEventArgs()); + await cut.Find(".resource-row-link[aria-label='api']").ClickAsync(new MouseEventArgs { CtrlKey = true }); + cut.Render(); + + Assert.Empty(cut.Instance.SelectedResourceNames); + Assert.Equal("true", cut.Find("#resourcePanePickerButton").GetAttribute("aria-expanded")); + Assert.Single(cut.FindAll(".resource-picker-popover .resource-pane")); + + navigation.NavigateTo(appHostEnabled ? DashboardUrls.ResourceOverviewUrl("cache") : DashboardUrls.StructuredLogsUrl("cache")); + cut.Render(); + Assert.Equal(["cache"], cut.Instance.SelectedResourceNames); + Assert.Equal("false", cut.Find("#resourcePanePickerButton").GetAttribute("aria-expanded")); + Assert.Empty(cut.FindAll(".resource-pane")); + } + + [Theory] + [InlineData("Control")] + [InlineData("Shift")] + [InlineData("Meta")] + public async Task CollapsedPane_ModifierGroupSelectionKeepsPickerOpen(string modifier) + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true, + initialResources: + [ + ModelTestHelpers.CreateResource("api", resourceType: KnownResourceTypes.Project), + ModelTestHelpers.CreateResource("worker", resourceType: KnownResourceTypes.Project), + ModelTestHelpers.CreateResource("cache") + ], + resourceChannelProvider: Channel.CreateUnbounded>)); + var storage = Assert.IsType(Services.GetRequiredService()); + storage.OnGetUnprotectedAsync = key => key switch + { + BrowserStorageKeys.ResourcePaneCollapsed => (true, true), + BrowserStorageKeys.ResourcePaneGrouping => (true, nameof(ResourcePaneGrouping.ResourceType)), + _ => (false, null) + }; + var navigation = Services.GetRequiredService(); + navigation.NavigateTo(DashboardUrls.ResourceOverviewUrl("api")); + var cut = Render(p => p.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + await cut.Find("#resourcePanePickerButton").ClickAsync(new MouseEventArgs()); + var click = new MouseEventArgs + { + CtrlKey = modifier == "Control", + ShiftKey = modifier == "Shift", + MetaKey = modifier == "Meta" + }; + var groupUrl = DashboardUrls.AddResourceSelection(DashboardUrls.ResourceListUrl(), ["api", "worker"]); + await cut.FindAll(".resource-tag-link").Single(link => link.GetAttribute("href") == groupUrl).ClickAsync(click); + cut.Render(); + + Assert.Equal(["api", "worker"], cut.Instance.SelectedResourceNames); + Assert.Equal("true", cut.Find("#resourcePanePickerButton").GetAttribute("aria-expanded")); + await cut.FindAll(".resource-tag-link").Single(link => link.GetAttribute("href") == groupUrl).ClickAsync(click); + cut.Render(); + Assert.Empty(cut.Instance.SelectedResourceNames); + Assert.Equal("true", cut.Find("#resourcePanePickerButton").GetAttribute("aria-expanded")); + Assert.Single(cut.FindAll(".resource-picker-popover .resource-pane")); + } + [Fact] public async Task TabOverflowRegistration_UsesStableLayoutIdForCleanup() { @@ -651,7 +931,7 @@ public async Task AppHost_GroupReorderingRequiresClearSearchAndFilters(string fi [Theory] [InlineData(false)] [InlineData(true)] - public void AppHost_ResourceLinksHaveAccessibleMetadataInsteadOfHoverTooltips(bool collapsed) + public async Task AppHost_ResourceLinksHaveAccessibleMetadataInsteadOfHoverTooltips(bool collapsed) { var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); var channel = Channel.CreateUnbounded>(); @@ -665,6 +945,11 @@ public void AppHost_ResourceLinksHaveAccessibleMetadataInsteadOfHoverTooltips(bo var cut = Render(builder => builder.AddCascadingValue(viewport)); cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + if (collapsed) + { + await cut.Find("#resourcePanePickerButton").ClickAsync(new MouseEventArgs()); + } + Assert.Equal( [ ("cache", "Running; Container (persistent); Health state: Healthy", (string?)null) @@ -687,6 +972,11 @@ public async Task Standalone_SourceLinksHaveAccessibleNamesInsteadOfHoverTooltip var cut = Render(builder => builder.AddCascadingValue(viewport)); cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + if (collapsed) + { + await cut.Find("#resourcePanePickerButton").ClickAsync(new MouseEventArgs()); + } + Assert.Equal( [ ("All sources", "1 telemetry source", (string?)null), diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs index 28cb329f88b..ac8715ae0ca 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs @@ -64,6 +64,8 @@ public static void SetupResourcesLayout(BunitContext context, ViewportInformatio module.SetupVoid("unregisterResourceContextMenu", _ => true).SetVoidResult(); module.SetupVoid("registerPaneActivityVisibility", _ => true).SetVoidResult(); module.SetupVoid("unregisterPaneActivityVisibility", _ => true).SetVoidResult(); + module.SetupVoid("setResourcePickerOpen", _ => true).SetVoidResult(); + module.SetupVoid("unregisterResourcePicker", _ => true).SetVoidResult(); module.SetupVoid("restoreResourceContextMenuFocus", _ => true).SetVoidResult(); module.SetupVoid("startPaneSelectionReveal", _ => true).SetVoidResult(); module.SetupVoid("focusPaneSelectionReveal", _ => true).SetVoidResult(); diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneMotionTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneMotionTests.cs index a3153678725..37c3f9243be 100644 --- a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneMotionTests.cs +++ b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneMotionTests.cs @@ -46,13 +46,16 @@ await page.EvaluateAsync(""" observer.disconnect(); const frames=[]; const sample=() => { - const pane=layout.querySelector('.resource-pane'), row=pane.querySelector('.resource-row[data-resource-name]'); - const b=pane.getBoundingClientRect(),r=row.getBoundingClientRect(), + const pane=layout.querySelector(':scope > .resource-pane'), row=pane?.querySelector('.resource-row[data-resource-name]'); + const b=pane?.getBoundingClientRect(),r=row?.getBoundingClientRect(), content=layout.querySelector('.resource-content'),c=content.getBoundingClientRect(), workspace=layout.getBoundingClientRect(); - frames.push([b.width,r.width,r.height,document.documentElement.scrollWidth, + // Resolved tracks look like "420px 1020px". The first track still + // animates after its pane is unmounted. + const trackWidth=parseFloat(getComputedStyle(layout).gridTemplateColumns); + frames.push([trackWidth,r?.width??0,r?.height??0,document.documentElement.scrollWidth, document.documentElement.clientWidth,c.width,c.left,c.right,workspace.left, - workspace.right,b.right,getComputedStyle(content).transform==='none'?0:1]); + workspace.right,b?.right??workspace.left+trackWidth,getComputedStyle(content).transform==='none'?0:1]); if(layout.getAnimations().some(a => a.playState==='running')) requestAnimationFrame(sample); else resolve(frames); }; @@ -75,8 +78,16 @@ await page.EvaluateAsync(""" } Assert.All(frames, frame => { - Assert.Equal(originalRow.Width, frame[1]); - Assert.Equal(originalRow.Height, frame[2]); + if (frame[1] > 0) + { + Assert.Equal(originalRow.Width, frame[1]); + Assert.Equal(originalRow.Height, frame[2]); + } + else + { + Assert.True(collapse); + Assert.Equal(0, frame[2]); + } Assert.True(frame[3] <= frame[4]); Assert.InRange(Math.Abs(frame[7] - frame[9]), 0, .5); Assert.InRange(Math.Abs(frame[6] - frame[10]), 0, .5); @@ -87,8 +98,7 @@ await page.EvaluateAsync(""" await Assertions.Expect(toggle).ToHaveAttributeAsync("aria-expanded", collapse ? "false" : "true"); if (collapse) { - await Assertions.Expect(pane).Not.ToBeVisibleAsync(); - await Assertions.Expect(pane).ToHaveAttributeAsync("inert", ""); + await Assertions.Expect(pane).ToHaveCountAsync(0); await Assertions.Expect(separator).ToHaveCountAsync(0); var content = await page.Locator(".resource-content").BoundingBoxAsync(); var layout = await page.Locator(".resources-layout").BoundingBoxAsync(); diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs index 75d952be8e9..3e24863419d 100644 --- a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs +++ b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs @@ -35,6 +35,68 @@ namespace Aspire.Dashboard.Tests.Integration.Playwright; public sealed class ResourcePaneResponsiveTests(ResourcePaneResponsiveTests.MetadataResourcesFixture fixture) : PlaywrightTestsBase(fixture) { + [Theory] + [InlineData(false)] + [InlineData(true)] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task CollapsedPicker_ControlsRemainReachableAtPopupWidth(bool savedWidth) + { + await RunTestAsync(async page => + { + await page.SetViewportSizeAsync(1440, 900); + await page.AddInitScriptAsync($"localStorage.setItem('{BrowserStorageKeys.ResourcePaneCollapsed}', 'true')"); + if (savedWidth) + { + await page.AddInitScriptAsync($"localStorage.setItem('{BrowserStorageKeys.ResourcePaneWidth}', '560')"); + } + await page.GotoAsync("/resources/cache?pane=tags"); + var picker = page.Locator("#resourcePanePickerButton"); + await picker.ClickAsync(); + var popup = page.Locator(".resource-picker-popover:popover-open"); + var selectAll = popup.Locator(".resource-select-all-toggle"); + await Assertions.Expect(selectAll).ToBeInViewportAsync(new() { Ratio = 1 }); + await Assertions.Expect(popup.Locator(".resource-row[data-resource-name='cache'] .resource-row-actions").First) + .ToBeInViewportAsync(new() { Ratio = 1 }); + await selectAll.ClickAsync(); + await Assertions.Expect(picker).ToHaveAttributeAsync("aria-expanded", "false"); + await Assertions.Expect(page).ToHaveURLAsync(new Regex(@"/resources(?:\?.*)?$")); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task Activity_ResumesAfterAnimatedExpansion(bool remountPane) + { + await RunTestAsync(async page => + { + await fixture.SeedActivityAsync(); + await page.SetViewportSizeAsync(2000, 1000); + await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.NoPreference }); + await page.GotoAsync("/resources/cache?pane=tags"); + await Assertions.Expect(page.Locator(".resource-row[data-resource-name='cache']").First).ToBeVisibleAsync(); + if (remountPane) + { + var toggle = page.Locator(".resource-header-row .resource-pane-toggle-button"); + await toggle.ClickAsync(); + await Assertions.Expect(page.Locator(".resources-layout > .resource-pane")).ToHaveCountAsync(0); + var picker = page.Locator("#resourcePanePickerButton"); + await picker.ClickAsync(); + await Assertions.Expect(page.Locator(".resource-picker-popover .resource-pane")).ToBeVisibleAsync(); + await page.Keyboard.PressAsync("Escape"); + await Assertions.Expect(picker).ToHaveAttributeAsync("aria-expanded", "false"); + await toggle.ClickAsync(); + await Assertions.Expect(page.Locator(".resources-layout > .resource-pane")).ToBeVisibleAsync(); + } + await ResourcePaneNavigation.ToggleResourceListAsync(page); + await Assertions.Expect(page.Locator(".resource-row[data-resource-name='api'] .resource-row-insight-requests").First) + .ToHaveTextAsync("20 incoming"); + await ResourcePaneNavigation.ToggleResourceListAsync(page); + await Assertions.Expect(page.Locator(".resource-row-insight")).ToHaveCountAsync(0); + }); + } + [Theory] [InlineData(390, false)] [InlineData(390, true)]