diff --git a/src/Components/Web/src/EnvironmentBoundary.cs b/src/Components/Web/src/EnvironmentBoundary.cs new file mode 100644 index 000000000000..2ef9bdb52476 --- /dev/null +++ b/src/Components/Web/src/EnvironmentBoundary.cs @@ -0,0 +1,143 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.AspNetCore.Components.Web; + +/// +/// A component that renders its child content only when the current hosting environment +/// matches one of the specified environment names. +/// +/// +/// +/// This component is similar to the environment tag helper in MVC and Razor Pages. +/// +/// +/// The following example shows how to conditionally render content based on the environment: +/// +/// <EnvironmentBoundary Include="Development"> +/// <div class="alert alert-warning"> +/// You are running in Development mode. Debug features are enabled. +/// </div> +/// </EnvironmentBoundary> +/// +/// <EnvironmentBoundary Include="Development, Staging"> +/// <p>This is a pre-production environment.</p> +/// </EnvironmentBoundary> +/// +/// <EnvironmentBoundary Exclude="Production"> +/// <p>Debug information: @DateTime.Now</p> +/// </EnvironmentBoundary> +/// +/// +/// +public sealed class EnvironmentBoundary : ComponentBase +{ + private static readonly char[] NameSeparator = [',']; + + [Inject] + private IHostEnvironment HostEnvironment { get; set; } = default!; + + /// + /// Gets or sets a comma-separated list of environment names in which the content should be rendered. + /// If the current environment is also in the list, the content will not be rendered. + /// + /// + /// The specified environment names are compared case insensitively. + /// + [Parameter] + public string? Include { get; set; } + + /// + /// Gets or sets a comma-separated list of environment names in which the content will not be rendered. + /// + /// + /// The specified environment names are compared case insensitively. + /// + [Parameter] + public string? Exclude { get; set; } + + /// + /// Gets or sets the content to be rendered when the environment matches. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (ShouldRenderContent()) + { + builder.AddContent(0, ChildContent); + } + } + + private bool ShouldRenderContent() + { + var currentEnvironmentName = HostEnvironment.EnvironmentName?.Trim(); + + if (string.IsNullOrEmpty(currentEnvironmentName)) + { + // For consistency with MVC EnvironmentTagHelper, render content when environment name is not set + // and no Include/Exclude are specified. + if (string.IsNullOrWhiteSpace(Include) && string.IsNullOrWhiteSpace(Exclude)) + { + return true; + } + + return false; + } + + // Check exclusions first - if current environment is excluded, don't render + if (!string.IsNullOrWhiteSpace(Exclude)) + { + foreach (var environment in ParseEnvironmentNames(Exclude)) + { + if (environment.Equals(currentEnvironmentName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + } + + // If no inclusions specified, render (unless excluded above) + if (string.IsNullOrWhiteSpace(Include)) + { + return true; + } + + // Check if current environment is in the include list + var hasEnvironments = false; + foreach (var environment in ParseEnvironmentNames(Include)) + { + hasEnvironments = true; + if (environment.Equals(currentEnvironmentName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + // If Include was specified but contained no valid environments, render content + // (same behavior as when Include is not specified) + if (!hasEnvironments) + { + return true; + } + + return false; + } + + private static IEnumerable ParseEnvironmentNames(string names) + { + foreach (var segment in names.Split(NameSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = segment.Trim(); + if (trimmed.Length > 0) + { + yield return trimmed; + } + } + } +} diff --git a/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj b/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj index 495a5b7529aa..294881d45b9b 100644 --- a/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj +++ b/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 369f33715778..8a2990b22bfa 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,4 +1,12 @@ #nullable enable +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.set -> void +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.EnvironmentBoundary() -> void +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.Exclude.get -> string? +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.Exclude.set -> void +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.Include.get -> string? +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.Include.set -> void Microsoft.AspNetCore.Components.Web.Media.FileDownload Microsoft.AspNetCore.Components.Web.Media.FileDownload.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? Microsoft.AspNetCore.Components.Web.Media.FileDownload.ChildContent.set -> void diff --git a/src/Components/Web/test/EnvironmentBoundaryTest.cs b/src/Components/Web/test/EnvironmentBoundaryTest.cs new file mode 100644 index 000000000000..95e1aa901705 --- /dev/null +++ b/src/Components/Web/test/EnvironmentBoundaryTest.cs @@ -0,0 +1,280 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Hosting; +using Moq; + +namespace Microsoft.AspNetCore.Components; + +public class EnvironmentBoundaryTest +{ + [Theory] + [InlineData("Development", "Development")] + [InlineData("development", "Development")] + [InlineData("DEVELOPMENT", "Development")] + [InlineData(" development", "Development")] + [InlineData("development ", "Development")] + [InlineData(" development ", "Development")] + [InlineData("Development,Production", "Development")] + [InlineData("Production,Development", "Development")] + [InlineData("Development , Production", "Development")] + [InlineData(" Development,Production ", "Development")] + [InlineData("Development , Production", "Development")] + [InlineData("Development\t,Production", "Development")] + [InlineData("Development,\tProduction", "Development")] + [InlineData(" Development,Production ", "Development")] + [InlineData("Development,Staging,Production", "Development")] + [InlineData("Staging,Development,Production", "Development")] + [InlineData("Staging,Production,Development", "Development")] + [InlineData("Test", "Test")] + [InlineData("Test,Staging", "Test")] + public void ShowsContentWhenCurrentEnvironmentIsInIncludeList(string includeAttribute, string environmentName) + { + ShouldShowContentWithInclude(includeAttribute, environmentName); + } + + [Theory] + [InlineData("NotDevelopment", "Development")] + [InlineData("NOTDEVELOPMENT", "Development")] + [InlineData("NotDevelopment,AlsoNotDevelopment", "Development")] + [InlineData("Doesn'tMatchAtAll", "Development")] + [InlineData("Development and a space", "Development")] + [InlineData("Development and a space,SomethingElse", "Development")] + public void HidesContentWhenCurrentEnvironmentIsNotInIncludeList(string includeAttribute, string environmentName) + { + ShouldHideContentWithInclude(includeAttribute, environmentName); + } + + [Theory] + [InlineData(null, "Development")] + [InlineData("", "Development")] + [InlineData(" ", "Development")] + [InlineData(", ", "Development")] + [InlineData(",", "Development")] + public void ShowsContentWhenNoIncludeOrExcludeIsSpecified(string includeAttribute, string environmentName) + { + // When no Include is specified and Exclude is not specified or empty, + // the component should render its content + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.Include), includeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.Contains(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData("Production", "Development")] + [InlineData("production", "Development")] + [InlineData("PRODUCTION", "Development")] + [InlineData("Production,Staging", "Development")] + public void ShowsContentWhenCurrentEnvironmentIsNotInExcludeList(string excludeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.Exclude), excludeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.Contains(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData("Development", "Development")] + [InlineData("development", "Development")] + [InlineData("DEVELOPMENT", "Development")] + [InlineData("Development,Staging", "Development")] + [InlineData("Production,Development", "Development")] + public void HidesContentWhenCurrentEnvironmentIsInExcludeList(string excludeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.Exclude), excludeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.DoesNotContain(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData("Development", "Development", "Development")] // In include, also in exclude -> hide + [InlineData("Development,Staging", "Staging", "Staging")] // In include, also in exclude -> hide + public void ExcludeTakesPrecedenceOverInclude(string includeAttribute, string excludeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.Include), includeAttribute }, + { nameof(EnvironmentBoundary.Exclude), excludeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.DoesNotContain(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData("Development", "Production", "Development")] // In include, not in exclude -> show + [InlineData("Development,Staging", "Production", "Staging")] // In include, not in exclude -> show + public void ShowsContentWhenInIncludeButNotInExclude(string includeAttribute, string excludeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.Include), includeAttribute }, + { nameof(EnvironmentBoundary.Exclude), excludeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.Contains(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + [InlineData("\t")] + public void HidesContentWhenEnvironmentNameIsNullOrEmpty(string environmentName) + { + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.Include), "Development" }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.DoesNotContain(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ShowsContentWhenEnvironmentNameIsNullOrEmptyAndNoIncludeExcludeSpecified(string environmentName) + { + // For consistency with MVC EnvironmentTagHelper, render content when environment name is not set + // and no Include/Exclude are specified. + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.Contains(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Fact] + public void RendersNothingWhenChildContentIsNull() + { + var (renderer, componentId) = CreateEnvironmentBoundaryComponent("Development"); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.Include), "Development" }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.DoesNotContain(batch.ReferenceFrames, f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + } + + private void ShouldShowContentWithInclude(string includeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.Include), includeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.Contains(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + private void ShouldHideContentWithInclude(string includeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.Include), includeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.DoesNotContain(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + private static (TestRenderer Renderer, int ComponentId) CreateEnvironmentBoundaryComponent(string environmentName) + { + var serviceProvider = new TestServiceProvider(); + var hostEnvironment = new Mock(); + hostEnvironment.SetupProperty(h => h.EnvironmentName, environmentName); + serviceProvider.AddService(hostEnvironment.Object); + + var renderer = new TestRenderer(serviceProvider); + var component = (EnvironmentBoundary)renderer.InstantiateComponent(); + var componentId = renderer.AssignRootComponentId(component); + + return (renderer, componentId); + } +} diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index c9971669d6c0..bacd10097d9c 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.Json; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.JSInterop; using static Microsoft.AspNetCore.Internal.LinkerFlags; @@ -185,6 +186,7 @@ private WebAssemblyHostEnvironment InitializeEnvironment() var hostEnvironment = new WebAssemblyHostEnvironment(applicationEnvironment, WebAssemblyNavigationManager.Instance.BaseUri); Services.AddSingleton(hostEnvironment); + Services.AddSingleton(sp => new WebAssemblyHostEnvironmentAdapter(sp.GetRequiredService())); var configFiles = new[] { diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs new file mode 100644 index 000000000000..bfa262f70c2d --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +internal sealed class WebAssemblyHostEnvironmentAdapter : IHostEnvironment +{ + private readonly IWebAssemblyHostEnvironment _webAssemblyHostEnvironment; + + public WebAssemblyHostEnvironmentAdapter(IWebAssemblyHostEnvironment webAssemblyHostEnvironment) + { + _webAssemblyHostEnvironment = webAssemblyHostEnvironment; + } + + public string EnvironmentName + { + get => _webAssemblyHostEnvironment.Environment; + set => throw new NotSupportedException("Setting the environment name is not supported in WebAssembly."); + } + + public string ApplicationName + { + get => string.Empty; + set => throw new NotSupportedException("Setting the application name is not supported in WebAssembly."); + } + + public string ContentRootPath + { + get => string.Empty; + set => throw new NotSupportedException("Setting the content root path is not supported in WebAssembly."); + } + + public IFileProvider ContentRootFileProvider + { + get => new NullFileProvider(); + set => throw new NotSupportedException("Setting the content root file provider is not supported in WebAssembly."); + } +} diff --git a/src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs b/src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs new file mode 100644 index 000000000000..96587c035aad --- /dev/null +++ b/src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BasicTestApp; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class EnvironmentBoundaryTest : ServerTestBase> +{ + public EnvironmentBoundaryTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + Navigate(ServerPathBase); + Browser.MountTestComponent(); + } + + [Fact] + public void RendersContentWhenEnvironmentMatches() + { + // Wait for the component to render and verify it's in Development + var currentEnvElement = Browser.Exists(By.Id("current-environment")); + Browser.Contains("Development", () => currentEnvElement.Text); + + var container = Browser.Exists(By.Id("environment-boundary-test")); + + // Verify Development-specific content is visible + var devContent = container.FindElement(By.Id("dev-only-content")); + Assert.Equal("This content is only visible in Development.", devContent.Text); + + // Verify non-production content is visible (we're in Development) + var nonProdContent = container.FindElement(By.Id("non-production-content")); + Assert.Equal("This content is visible in all environments except Production.", nonProdContent.Text); + + // Verify Development+Staging content is visible + var devStagingContent = container.FindElement(By.Id("dev-staging-content")); + Assert.Equal("This content is visible in Development and Staging.", devStagingContent.Text); + } + + [Fact] + public void HidesContentWhenEnvironmentDoesNotMatch() + { + // Wait for the component to render and verify it's in Development + var currentEnvElement = Browser.Exists(By.Id("current-environment")); + Browser.Contains("Development", () => currentEnvElement.Text); + + var container = Browser.Exists(By.Id("environment-boundary-test")); + + // Production-only content should not be visible in Development + Browser.DoesNotExist(By.Id("prod-only-content")); + + // Content excluded from Development should not be visible + Browser.DoesNotExist(By.Id("non-dev-content")); + } + + [Fact] + public void DisplaysCurrentEnvironment() + { + // Verify the environment is displayed correctly + var currentEnvElement = Browser.Exists(By.Id("current-environment")); + Browser.Contains("Development", () => currentEnvElement.Text); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/EnvironmentBoundaryContainer.razor b/src/Components/test/testassets/BasicTestApp/EnvironmentBoundaryContainer.razor new file mode 100644 index 000000000000..015a1bd342b4 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/EnvironmentBoundaryContainer.razor @@ -0,0 +1,42 @@ +@using Microsoft.AspNetCore.Components.Web + +

Environment Boundary Test

+ +
+ +
+ This content is only visible in Development. +
+
+ + +
+ This content is visible in all environments except Production. +
+
+ + +
+ This content is visible in Development and Staging. +
+
+ + +
+ This content is only visible in Production. +
+
+ + +
+ This content is visible in all environments except Development. +
+
+
+ +

Current Environment: @HostEnvironment.EnvironmentName

+ +@code { + [Inject] + public Microsoft.Extensions.Hosting.IHostEnvironment HostEnvironment { get; set; } = default!; +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index f9cc718f7ca1..0b02a85af02c 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -31,6 +31,7 @@ +