From 55eb46f33d9f3073c3c6e9939b32c35b9f8c80fa Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sun, 11 Aug 2024 20:38:04 -0400 Subject: [PATCH] Add viewer for long strings in GridValue (#5018) * Move GridValue copy button inside a menu * add empty dialog * test * Style dialog, work around fluentui onclick issue for FluentMenuItem * Remove hardcoded string * Update unformatted name, use JsonSerializer.Serialize, use key comparer for dictionaries, switch button appearance and icon * Display dialog like in console logs * Format based on language * stream data * Add TextVisualizerDialog component tests * fix logical error * fix console log css * Don't loose comments when formatting JSON * run custom tool after merge * Update ThemeManager to obtain effective theme, conditionally highlight in light or dark mode based on effective theme * remove unnecessary top margin, re-highlight if container content has changed * Add more tests * start at line 1, move copy button to top of dialog * change width/height display of dialog * Update src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs * Style fixes * Change text visualizer theming from server to client side * Move parameter to codebehind, properly disconnect observer * Allow users to click the menu item icons to prompt copy/open text visualizer action * set width to 0 on cell menubutton to show as much column content as possible --------- Co-authored-by: Adam Ratzman Co-authored-by: James Newton-King --- src/Aspire.Dashboard/Components/App.razor | 2 + .../Components/Controls/GridValue.razor | 38 +- .../Components/Controls/GridValue.razor.cs | 34 +- .../Components/Controls/GridValue.razor.css | 2 + .../Components/Controls/LogViewer.razor | 14 +- .../Components/Controls/LogViewer.razor.css | 104 +- .../Components/Controls/PropertyGrid.razor | 13 +- .../Components/Controls/ResourceDetails.razor | 17 +- .../Dialogs/TextVisualizerDialog.razor | 72 + .../Dialogs/TextVisualizerDialog.razor.cs | 266 ++++ .../Dialogs/TextVisualizerDialog.razor.css | 8 + .../Dialogs/TextVisualizerDialog.razor.js | 49 + .../Components/Layout/MainLayout.razor | 1 + .../Components/Layout/MainLayout.razor.cs | 44 +- .../LogMessageColumnDisplay.razor | 2 + .../SourceColumnDisplay.razor | 5 +- .../DashboardWebApplication.cs | 1 + .../Extensions/FluentUIExtensions.cs | 18 +- .../Model/TextVisualizerDialogViewModel.cs | 6 + src/Aspire.Dashboard/Model/ThemeManager.cs | 65 +- .../Resources/Dialogs.Designer.cs | 314 ++--- src/Aspire.Dashboard/Resources/Dialogs.resx | 15 + .../Resources/xlf/Dialogs.cs.xlf | 25 + .../Resources/xlf/Dialogs.de.xlf | 25 + .../Resources/xlf/Dialogs.es.xlf | 25 + .../Resources/xlf/Dialogs.fr.xlf | 25 + .../Resources/xlf/Dialogs.it.xlf | 25 + .../Resources/xlf/Dialogs.ja.xlf | 25 + .../Resources/xlf/Dialogs.ko.xlf | 25 + .../Resources/xlf/Dialogs.pl.xlf | 25 + .../Resources/xlf/Dialogs.pt-BR.xlf | 25 + .../Resources/xlf/Dialogs.ru.xlf | 25 + .../Resources/xlf/Dialogs.tr.xlf | 25 + .../Resources/xlf/Dialogs.zh-Hans.xlf | 25 + .../Resources/xlf/Dialogs.zh-Hant.xlf | 25 + src/Aspire.Dashboard/wwwroot/css/app.css | 119 +- .../wwwroot/css/highlight.css | 59 + src/Aspire.Dashboard/wwwroot/js/app-theme.js | 16 +- src/Aspire.Dashboard/wwwroot/js/app.js | 66 +- .../wwwroot/js/highlight-11.10.0.min.js | 1230 +++++++++++++++++ src/Shared/StringComparers.cs | 1 + .../Controls/TextVisualizerDialogTests.cs | 164 +++ .../Shared/MetricsSetupHelpers.cs | 1 + .../Shared/TestEffectiveThemeResolver.cs | 14 + 44 files changed, 2722 insertions(+), 363 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor create mode 100644 src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.css create mode 100644 src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.js create mode 100644 src/Aspire.Dashboard/Model/TextVisualizerDialogViewModel.cs create mode 100644 src/Aspire.Dashboard/wwwroot/css/highlight.css create mode 100644 src/Aspire.Dashboard/wwwroot/js/highlight-11.10.0.min.js create mode 100644 tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Shared/TestEffectiveThemeResolver.cs diff --git a/src/Aspire.Dashboard/Components/App.razor b/src/Aspire.Dashboard/Components/App.razor index 1d82c5e648..d7ef3cf0b0 100644 --- a/src/Aspire.Dashboard/Components/App.razor +++ b/src/Aspire.Dashboard/Components/App.razor @@ -9,7 +9,9 @@ + + /,{relevance:10}),{begin://, + relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, + relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", + begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ + end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", + begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ + end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ + className:"tag",begin:/<>|<\/>/},{className:"tag", + begin:n.concat(//,/>/,/\s/)))), + end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ + className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ + className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} + },grmr_yaml:e=>{ + const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ + className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ + },{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", + variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{ + variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),r={ + end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},s={begin:/\{/, + end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={begin:"\\[",end:"\\]", + contains:[r],illegal:"\\n",relevance:0},l=[{className:"attr",variants:[{ + begin:/\w[\w :()\./-]*:(?=[ \t]|$)/},{begin:/"\w[\w :()\./-]*":(?=[ \t]|$)/},{ + begin:/'\w[\w :()\./-]*':(?=[ \t]|$)/}]},{className:"meta",begin:"^---\\s*$", + relevance:10},{className:"string", + begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ + begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, + relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", + begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t + },{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", + begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", + relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ + className:"number", + begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" + },{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,a],c=[...l] + ;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, + aliases:["yml"],contains:l}}});const Ke=te;for(const e of Object.keys(Pe)){ + const n=e.replace("grmr_","").replace("_","-");Ke.registerLanguage(n,Pe[e])} +export{Ke as default}; diff --git a/src/Shared/StringComparers.cs b/src/Shared/StringComparers.cs index 6126b0fcd2..15ac86e0c7 100644 --- a/src/Shared/StringComparers.cs +++ b/src/Shared/StringComparers.cs @@ -15,6 +15,7 @@ internal static class StringComparers public static StringComparer EnvironmentVariableName => StringComparer.InvariantCultureIgnoreCase; public static StringComparer UrlPath => StringComparer.OrdinalIgnoreCase; public static StringComparer UrlHost => StringComparer.OrdinalIgnoreCase; + public static StringComparer Attribute => StringComparer.Ordinal; } internal static class StringComparisons diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs new file mode 100644 index 0000000000..e6e93ac3d3 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs @@ -0,0 +1,164 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using Aspire.Dashboard.Components.Dialogs; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +public class TextVisualizerDialogTests : TestContext +{ + [Fact] + public async Task Render_TextVisualizerDialog_WithValidJson_FormatsJsonAsync() + { + var rawJson = """ + // line comment + [ + /* block comment */ + 1, + { "test": { "nested": "value" } } + ] + """; + + var expectedJson = """ + /* line comment*/ + [ + /* block comment */ + 1, + { + "test": { + "nested": "value" + } + } + ] + """; + + var cut = SetUpDialog(out var dialogService, out _); + await dialogService.ShowDialogAsync(new TextVisualizerDialogViewModel(rawJson, string.Empty), []); + + var instance = cut.FindComponent().Instance; + + Assert.Equal(expectedJson, instance.FormattedText); + Assert.Equal(TextVisualizerDialog.JsonFormat, instance.FormatKind); + Assert.Equal([TextVisualizerDialog.JsonFormat, TextVisualizerDialog.PlaintextFormat], instance.EnabledOptions.ToImmutableSortedSet()); + } + + [Fact] + public async Task Render_TextVisualizerDialog_WithValidXml_FormatsXml_CanChangeFormatAsync() + { + const string rawXml = """text"""; + const string expectedXml = + """ + + + text + + """; + + var cut = SetUpDialog(out var dialogService, out _); + await dialogService.ShowDialogAsync(new TextVisualizerDialogViewModel(rawXml, string.Empty), []); + + var instance = cut.FindComponent().Instance; + + Assert.Equal(TextVisualizerDialog.XmlFormat, instance.FormatKind); + Assert.Equal(expectedXml, instance.FormattedText); + Assert.Equal([TextVisualizerDialog.PlaintextFormat, TextVisualizerDialog.XmlFormat], instance.EnabledOptions.ToImmutableSortedSet()); + + // changing format works + instance.ChangeFormat(TextVisualizerDialog.PlaintextFormat, rawXml); + + Assert.Equal(TextVisualizerDialog.PlaintextFormat, instance.FormatKind); + Assert.Equal(rawXml, instance.FormattedText); + } + + [Fact] + public async Task Render_TextVisualizerDialog_WithValidXml_FormatsXmlWithDoctypeAsync() + { + const string rawXml = """text content"""; + const string expectedXml = + """ + + text content + """; + + var cut = SetUpDialog(out var dialogService, out _); + await dialogService.ShowDialogAsync(new TextVisualizerDialogViewModel(rawXml, string.Empty), []); + + var instance = cut.FindComponent().Instance; + + Assert.Equal(TextVisualizerDialog.XmlFormat, instance.FormatKind); + Assert.Equal(expectedXml, instance.FormattedText); + Assert.Equal([TextVisualizerDialog.PlaintextFormat, TextVisualizerDialog.XmlFormat], instance.EnabledOptions.ToImmutableSortedSet()); + } + + [Fact] + public async Task Render_TextVisualizerDialog_WithInvalidJson_FormatsPlaintextAsync() + { + const string rawText = """{{{{{{"test": 4}"""; + + var cut = SetUpDialog(out var dialogService, out _); + await dialogService.ShowDialogAsync(new TextVisualizerDialogViewModel(rawText, string.Empty), []); + + var instance = cut.FindComponent().Instance; + + Assert.Equal(TextVisualizerDialog.PlaintextFormat, instance.FormatKind); + Assert.Equal(rawText, instance.FormattedText); + Assert.Equal([TextVisualizerDialog.PlaintextFormat], instance.EnabledOptions.ToImmutableSortedSet()); + } + + [Fact] + public async Task Render_TextVisualizerDialog_WithDifferentThemes_LineClassesChange() + { + var xml = @""; + + var cut = SetUpDialog(out var dialogService, out var themeManager); + themeManager.EffectiveTheme = "Light"; + await dialogService.ShowDialogAsync(new TextVisualizerDialogViewModel(xml, string.Empty), []); + + Assert.NotEmpty(cut.FindAll(".theme-a11y-light-min")); + + themeManager.EffectiveTheme = "Dark"; + var instance = cut.FindComponent(); + instance.Render(); + + Assert.NotEmpty(cut.FindAll(".theme-a11y-dark-min")); + } + + [Fact] + public async Task Render_TextVisualizerDialog_ResolveTheme_LineClassesChange() + { + var xml = @""; + + var cut = SetUpDialog(out var dialogService, out var _); + await dialogService.ShowDialogAsync(new TextVisualizerDialogViewModel(xml, string.Empty), []); + + Assert.NotEmpty(cut.FindAll(".theme-a11y-dark-min")); + } + + private IRenderedFragment SetUpDialog(out IDialogService dialogService, out ThemeManager themeManager) + { + themeManager = new ThemeManager(new TestEffectiveThemeResolver()); + + Services.AddFluentUIComponents(); + Services.AddSingleton(themeManager); + Services.AddSingleton(); + Services.AddLocalization(); + var module = JSInterop.SetupModule("/Components/Dialogs/TextVisualizerDialog.razor.js"); + module.SetupVoid(); + + var cut = Render(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + + dialogService = Services.GetRequiredService(); + return cut; + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs index f4578a8f60..774ed15c8a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs @@ -80,6 +80,7 @@ internal static void SetupMetricsPage(TestContext context) context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); + context.Services.AddSingleton(); context.Services.AddSingleton(); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestEffectiveThemeResolver.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestEffectiveThemeResolver.cs new file mode 100644 index 0000000000..6cdf3cb5fb --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestEffectiveThemeResolver.cs @@ -0,0 +1,14 @@ +// 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; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +public sealed class TestEffectiveThemeResolver : IEffectiveThemeResolver +{ + public Task GetEffectiveThemeAsync(CancellationToken cancellationToken) + { + return Task.FromResult("Dark"); + } +}