// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Components.Tests.Shared;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.FluentUI.AspNetCore.Components;
using Xunit;
namespace Aspire.Dashboard.Components.Tests.Controls;
[UseCulture("en-US")]
public class ChartFiltersTests : DashboardTestContext
{
[Fact]
public void AreAllValuesSelected_SetFalse_ClearsOnlyWhenAllValuesAreStored()
{
var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET", });
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST", });
dimensionFilter.SetSelectedValues(dimensionFilter.Values);
Assert.True(dimensionFilter.AreAllValuesSelected);
dimensionFilter.AreAllValuesSelected = false;
Assert.Empty(dimensionFilter.SelectedValues);
}
[Fact]
public void AreAllValuesSelected_SetFalse_DoesNotClearWhenPartiallySelected()
{
var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET", });
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST", });
dimensionFilter.SetSelectedValues([dimensionFilter.Values[0]]);
Assert.Null(dimensionFilter.AreAllValuesSelected);
dimensionFilter.AreAllValuesSelected = false;
Assert.Single(dimensionFilter.SelectedValues);
Assert.Equal("GET", dimensionFilter.SelectedValues.First().Value);
}
[Fact]
public void AreAllValuesSelected_SetTrue_SelectsAllValues()
{
var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET", });
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST", });
dimensionFilter.SetSelectedValues([dimensionFilter.Values[0]]);
dimensionFilter.AreAllValuesSelected = true;
Assert.Equal(dimensionFilter.Values.Count, dimensionFilter.SelectedValues.Count);
Assert.True(dimensionFilter.AreAllValuesSelected);
}
[Fact]
public void OnTagSelectionChanged_ReplacesSnapshotAndRemovesValue()
{
var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
var getValue = new DimensionValueViewModel { Text = "GET", Value = "GET", };
var postValue = new DimensionValueViewModel { Text = "POST", Value = "POST", };
dimensionFilter.Values.Add(getValue);
dimensionFilter.Values.Add(postValue);
dimensionFilter.SetSelectedValues([getValue, postValue]);
var selectedValuesSnapshot = dimensionFilter.SelectedValues;
dimensionFilter.OnTagSelectionChanged(getValue, isChecked: false);
Assert.Single(dimensionFilter.SelectedValues);
Assert.Contains(postValue, dimensionFilter.SelectedValues);
Assert.True(selectedValuesSnapshot.SetEquals([getValue, postValue]));
}
[Fact]
public void Render_AllValuesSelected_ShowsAllState()
{
SetupChartFilters();
var dimensionFilter = CreateDimensionFilter();
dimensionFilter.AreAllValuesSelected = true;
var cut = RenderChartFilters(dimensionFilter);
Assert.DoesNotContain("(None)", cut.Markup);
Assert.Single(cut.FindAll(".chart-filter-button-container"));
Assert.Contains("aria-label=\"All tags\"", cut.Markup);
Assert.NotNull(cut.Find(".dimension-popup-container"));
Assert.All(cut.FindAll(".dimension-popup fluent-field"), field => Assert.Contains("aspire-checkbox", field.ClassList));
Assert.Equal("0", cut.Find(".dimension-overflow").GetAttribute("threshold"));
var overflowItems = cut.FindAll(".dimension-overflow > div:not(.fluent-overflow-more)");
Assert.Equal("ellipsis", overflowItems[0].GetAttribute("behavior"));
Assert.Contains("dimension-overflow-ellipsis", overflowItems[0].ClassList);
Assert.All(overflowItems.Skip(1), item => Assert.Null(item.GetAttribute("behavior")));
Assert.All(overflowItems.Skip(1), item => Assert.DoesNotContain("dimension-overflow-ellipsis", item.ClassList));
}
[Fact]
public void Render_MoreThanMaxTags_RendersBoundedPayloadAndHighlightsSelectedOverflow()
{
SetupChartFilters();
var dimensionFilter = new DimensionFilterViewModel { Name = "http.status_code" };
for (var i = 0; i < 30; i++)
{
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = i.ToString(), Value = i.ToString() });
}
dimensionFilter.SetSelectedValues([dimensionFilter.Values[25]]);
var cut = RenderChartFilters(dimensionFilter);
var overflow = cut.Find(".dimension-overflow");
var overflowItems = cut.FindAll(".dimension-overflow > div:not(.fluent-overflow-more)");
var moreButton = cut.Find(".dimension-overflow .fluent-overflow-more .filter-value-tag");
Assert.Equal("10", overflow.GetAttribute("pre-overflow-count"));
Assert.Equal(20, overflowItems.Count);
Assert.Equal("+10", moreButton.TextContent.Trim());
Assert.Contains("included-in-filters", moreButton.ClassList);
}
[Fact]
public void Click_FilterValueOverflow_OpensPopover()
{
SetupChartFilters();
var dimensionFilter = new DimensionFilterViewModel { Name = "http.status_code" };
for (var i = 0; i < 30; i++)
{
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = i.ToString(), Value = i.ToString() });
}
var cut = RenderChartFilters(dimensionFilter);
cut.Find(".dimension-overflow .fluent-overflow-more .filter-value-tag").Click();
Assert.Equal("true", cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("opened"));
}
[Fact]
public void Render_PartiallySelectedValues_ShowsIndeterminateAllCheckbox()
{
SetupChartFilters();
var dimensionFilter = CreateDimensionFilter();
dimensionFilter.SetSelectedValues([dimensionFilter.Values[0]]);
var cut = RenderChartFilters(dimensionFilter);
var allCheckbox = cut.FindComponents<FluentCheckbox>()[0].Instance;
Assert.False(allCheckbox.Value);
Assert.Null(allCheckbox.CheckState);
}
[Fact]
public async Task SelectionChanged_HighlightsFilterButtonUntilAllValuesSelected()
{
SetupChartFilters();
var dimensionFilter = CreateDimensionFilter();
dimensionFilter.AreAllValuesSelected = true;
var cut = RenderChartFilters(dimensionFilter);
var popover = cut.FindComponent<ChartFilterPopover>();
var buttonId = cut.Find(".chart-filter-button").Id;
var checkboxes = popover.FindComponents<FluentCheckbox>();
AssertButtonAppearance(highlighted: false);
await cut.InvokeAsync(() => checkboxes[1].Instance.ValueChanged.InvokeAsync(false));
AssertButtonAppearance(highlighted: true);
await cut.InvokeAsync(() => checkboxes[0].Instance.CheckStateChanged.InvokeAsync(true));
AssertButtonAppearance(highlighted: false);
await cut.InvokeAsync(() => checkboxes[0].Instance.CheckStateChanged.InvokeAsync(false));
AssertButtonAppearance(highlighted: true);
await cut.InvokeAsync(() => checkboxes[0].Instance.CheckStateChanged.InvokeAsync(true));
AssertButtonAppearance(highlighted: false);
void AssertButtonAppearance(bool highlighted)
{
cut.WaitForAssertion(() =>
{
var button = cut.Find(".chart-filter-button");
Assert.Equal(buttonId, button.Id);
Assert.Equal(highlighted ? "primary" : "transparent", button.GetAttribute("appearance"));
Assert.Equal(highlighted ? "Filtered tags" : "All tags", button.GetAttribute("aria-label"));
var icon = cut.FindComponent<FluentIcon<Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.Filter>>().Instance;
Assert.Equal(Color.Custom, icon.Color);
Assert.Equal(highlighted ? "currentColor" : "var(--colorBrandForeground1)", icon.CustomColor);
});
}
}
[Fact]
public void Click_FilterButton_KeepsStableAnchorAndOpensPopover()
{
SetupChartFilters();
var dimensionFilter = CreateDimensionFilter();
var cut = RenderChartFilters(dimensionFilter);
var button = cut.Find(".chart-filter-button");
var buttonId = button.Id;
button.Click();
Assert.Equal(buttonId, cut.Find(".chart-filter-button").Id);
var popover = cut.Find("fluent-popover-b");
Assert.Equal(buttonId, popover.GetAttribute("anchor-id"));
Assert.Equal("true", popover.GetAttribute("opened"));
Assert.Contains("chart-filter-popover", popover.ClassList);
}
[Fact]
public void Render_NamesWithSameSanitizedId_HaveDistinctPopoverAnchors()
{
SetupChartFilters();
var firstFilter = new DimensionFilterViewModel { Name = "foo.bar" };
var secondFilter = new DimensionFilterViewModel { Name = "foo/bar" };
var cut = RenderChartFilters(firstFilter);
cut.Render(builder => builder.Add(component => component.DimensionFilters, [firstFilter, secondFilter]));
var buttons = cut.FindAll(".chart-filter-button");
var popovers = cut.FindAll("fluent-popover-b.chart-filter-popover");
Assert.Equal(2, buttons.Count);
Assert.Equal(2, popovers.Count);
Assert.NotEqual(buttons[0].Id, buttons[1].Id);
Assert.StartsWith("typeFilterButton-foo_bar-", buttons[0].Id);
Assert.StartsWith("typeFilterButton-foo_bar-", buttons[1].Id);
Assert.Equal(buttons[0].Id, popovers[0].GetAttribute("anchor-id"));
Assert.Equal(buttons[1].Id, popovers[1].GetAttribute("anchor-id"));
buttons[1].Click();
Assert.Equal("false", cut.FindAll("fluent-popover-b.chart-filter-popover")[0].GetAttribute("opened"));
Assert.Equal("true", cut.FindAll("fluent-popover-b.chart-filter-popover")[1].GetAttribute("opened"));
Assert.Equal(buttons[0].Id, cut.FindAll(".chart-filter-button")[0].Id);
Assert.Equal(buttons[1].Id, cut.FindAll(".chart-filter-button")[1].Id);
}
[Fact]
public void ReplaceFilterSnapshot_KeepsOpenPopoverMounted()
{
SetupChartFilters();
var dimensionFilter = CreateDimensionFilter();
var cut = RenderChartFilters(dimensionFilter);
var buttonId = cut.Find(".chart-filter-button").Id;
var popover = cut.FindComponent<ChartFilterPopover>().Instance;
cut.Find(".chart-filter-button").Click();
var updatedFilter = CreateDimensionFilter();
updatedFilter.Values.Add(new DimensionValueViewModel { Text = "PUT", Value = "PUT", });
updatedFilter.SetSelectedValues(updatedFilter.Values);
cut.Render(builder => builder.Add(component => component.DimensionFilters, [updatedFilter]));
Assert.Same(popover, cut.FindComponent<ChartFilterPopover>().Instance);
Assert.Same(updatedFilter, popover.Filter);
Assert.Equal(buttonId, cut.Find(".chart-filter-button").Id);
Assert.Equal(buttonId, cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("anchor-id"));
Assert.Equal("true", cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("opened"));
Assert.Equal(4, cut.FindAll(".dimension-popup fluent-field").Count);
}
[Fact]
public void Render_FilterValueTags_UseNativeButtons()
{
SetupChartFilters();
var dimensionFilter = CreateDimensionFilter();
dimensionFilter.SetSelectedValues([dimensionFilter.Values.Single(v => v.Text == "POST")]);
var cut = RenderChartFilters(dimensionFilter);
var getTag = cut.FindAll(".filter-value-tag").Single(e => e.TextContent.Trim() == "GET");
Assert.Equal("BUTTON", getTag.TagName);
Assert.Equal("button", getTag.GetAttribute("type"));
}
[Fact]
public void Click_FilterValueTag_SelectsOnlyClickedValue()
{
SetupChartFilters();
var dimensionFilter = CreateDimensionFilter();
dimensionFilter.SetSelectedValues([dimensionFilter.Values.Single(v => v.Text == "POST")]);
var cut = RenderChartFilters(dimensionFilter);
var getTag = cut.FindAll(".filter-value-tag").Single(e => e.TextContent.Trim() == "GET");
getTag.Click(new MouseEventArgs());
var selectedValue = Assert.Single(dimensionFilter.SelectedValues);
Assert.Equal("GET", selectedValue.Text);
}
[Fact]
public void ShiftClick_UnselectedFilterValueTag_AddsClickedValue()
{
SetupChartFilters();
var dimensionFilter = CreateDimensionFilter();
dimensionFilter.SetSelectedValues([dimensionFilter.Values.Single(v => v.Text == "POST")]);
var cut = RenderChartFilters(dimensionFilter);
var getTag = cut.FindAll(".filter-value-tag").Single(e => e.TextContent.Trim() == "GET");
getTag.Click(new MouseEventArgs { ShiftKey = true });
Assert.Equal(["GET", "POST"], dimensionFilter.SelectedValues.Select(v => v.Text).Order());
}
[Fact]
public void ShiftClick_SelectedFilterValueTag_RemovesClickedValue()
{
SetupChartFilters();
var dimensionFilter = CreateDimensionFilter();
dimensionFilter.SetSelectedValues(dimensionFilter.Values);
var cut = RenderChartFilters(dimensionFilter);
var getTag = cut.FindAll(".filter-value-tag").Single(e => e.TextContent.Trim() == "GET");
getTag.Click(new MouseEventArgs { ShiftKey = true });
var selectedValue = Assert.Single(dimensionFilter.SelectedValues);
Assert.Equal("POST", selectedValue.Text);
}
[Fact]
public void GetOrderedValues_NumericValues_OrdersNumerically()
{
var dimensionFilter = new DimensionFilterViewModel { Name = "http.status_code" };
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "200", Value = "200", });
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "404", Value = "404", });
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "50", Value = "50", });
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "1000", Value = "1000", });
var ordered = ChartFilterTags.GetOrderedValues(dimensionFilter.Values).Select(v => v.Text).ToList();
Assert.Equal(["50", "200", "404", "1000"], ordered);
}
[Fact]
public void GetOrderedValues_TextValues_OrdersAlphabetically()
{
var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST", });
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET", });
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "DELETE", Value = "DELETE", });
var ordered = ChartFilterTags.GetOrderedValues(dimensionFilter.Values).Select(v => v.Text).ToList();
Assert.Equal(["DELETE", "GET", "POST"], ordered);
}
private IRenderedComponent<ChartFilters> RenderChartFilters(
DimensionFilterViewModel dimensionFilter,
Action<DimensionFilterViewModel>? onDimensionValuesChanged = null)
{
return Render<ChartFilters>(builder =>
{
builder.Add(p => p.InstrumentType, OtlpInstrumentType.Sum);
builder.Add(p => p.ShowCount, false);
builder.Add(p => p.DimensionFilters, [dimensionFilter]);
if (onDimensionValuesChanged is not null)
{
builder.Add(p => p.OnDimensionValuesChanged, EventCallback.Factory.Create(this, onDimensionValuesChanged));
}
});
}
private void SetupChartFilters()
{
FluentUISetupHelpers.AddCommonDashboardServices(this);
FluentUISetupHelpers.SetupFluentDataGrid(this);
FluentUISetupHelpers.SetupFluentCheckbox(this);
FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
MetricsSetupHelpers.SetupChartContainer(this);
FluentUISetupHelpers.SetupFluentUIComponents(this);
}
private static DimensionFilterViewModel CreateDimensionFilter()
{
var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET", });
dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST", });
return dimensionFilter;
}
}