diff --git a/src/BootstrapBlazor/Components/BaseComponents/BootstrapBlazorRoot.razor b/src/BootstrapBlazor/Components/BaseComponents/BootstrapBlazorRoot.razor index b2c7f5ecd6b..7e9d6d825be 100644 --- a/src/BootstrapBlazor/Components/BaseComponents/BootstrapBlazorRoot.razor +++ b/src/BootstrapBlazor/Components/BaseComponents/BootstrapBlazorRoot.razor @@ -14,7 +14,7 @@ - + @foreach (var com in Generators) { @com.Generator() diff --git a/src/BootstrapBlazor/Components/BootstrapBlazorRootOutlet/BootstrapBlazorRootContent.cs b/src/BootstrapBlazor/Components/BootstrapBlazorRootOutlet/BootstrapBlazorRootContent.cs new file mode 100644 index 00000000000..63a2b2a63fd --- /dev/null +++ b/src/BootstrapBlazor/Components/BootstrapBlazorRootOutlet/BootstrapBlazorRootContent.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// BootstrapBlazorRootContent Component +/// +public class BootstrapBlazorRootContent : IComponent, IDisposable +{ + private object? _registeredIdentifier; + + /// + /// Gets or sets the ID that determines which instance will render + /// the content of this instance. + /// + [Parameter] public string? RootName { get; set; } + + /// + /// Gets or sets the ID that determines which instance will render + /// the content of this instance. + /// + [Parameter] public object? RootId { get; set; } + + /// + /// Gets or sets the content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Inject] + private BootstrapBlazorRootRegisterService RootRegisterService { get; set; } = default!; + + /// + /// + /// + /// + void IComponent.Attach(RenderHandle renderHandle) + { + + } + + /// + /// + /// + /// + /// + Task IComponent.SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + object? identifier = null; + + if (RootName is not null && RootId is not null) + { + throw new InvalidOperationException($"{nameof(BootstrapBlazorRootContent)} requires that '{nameof(RootName)}' and '{nameof(RootId)}' cannot both have non-null values."); + } + else if (RootName is not null) + { + identifier = RootName; + } + else if (RootId is not null) + { + identifier = RootId; + } + identifier ??= BootstrapBlazorRootOutlet.DefaultIdentifier; + + if (!Equals(identifier, _registeredIdentifier)) + { + if (_registeredIdentifier is not null) + { + RootRegisterService.RemoveProvider(_registeredIdentifier, this); + } + + RootRegisterService.AddProvider(identifier, this); + _registeredIdentifier = identifier; + } + + RootRegisterService.NotifyContentProviderChanged(identifier, this); + return Task.CompletedTask; + } + + /// + /// + /// + public void Dispose() + { + if (_registeredIdentifier is not null) + { + RootRegisterService.RemoveProvider(_registeredIdentifier, this); + } + GC.SuppressFinalize(this); + } +} diff --git a/src/BootstrapBlazor/Components/BootstrapBlazorRootOutlet/BootstrapBlazorRootOutlet.cs b/src/BootstrapBlazor/Components/BootstrapBlazorRootOutlet/BootstrapBlazorRootOutlet.cs new file mode 100644 index 00000000000..1eb29b987bb --- /dev/null +++ b/src/BootstrapBlazor/Components/BootstrapBlazorRootOutlet/BootstrapBlazorRootOutlet.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.AspNetCore.Components.Rendering; + +namespace BootstrapBlazor.Components; + +/// +/// BootstrapBlazorRootOutlet Component +/// +public class BootstrapBlazorRootOutlet : IComponent, IDisposable +{ + private static readonly RenderFragment _emptyRenderFragment = _ => { }; + private object? _subscribedIdentifier; + private RenderHandle _renderHandle; + + /// + /// Gets the default identifier that can be used to subscribe to all instances. + /// + public static readonly object DefaultIdentifier = new(); + + [Inject] + private BootstrapBlazorRootRegisterService RootRegisterService { get; set; } = default!; + + /// + /// Gets or sets the ID that determines which instances will provide + /// content to this instance. + /// + [Parameter] + public string? RootName { get; set; } + + /// + /// Gets or sets the ID that determines which instances will provide + /// content to this instance. + /// + [Parameter] + public object? RootId { get; set; } + + void IComponent.Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + /// + /// + /// + /// + /// + Task IComponent.SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + object? identifier = null; + + if (RootName is not null && RootId is not null) + { + throw new InvalidOperationException($"{nameof(BootstrapBlazorRootOutlet)} requires that '{nameof(RootName)}' and '{nameof(RootId)}' cannot both have non-null values."); + } + else if (RootName is not null) + { + identifier = RootName; + } + else if (RootId is not null) + { + identifier = RootId; + } + identifier ??= DefaultIdentifier; + + if (!Equals(identifier, _subscribedIdentifier)) + { + if (_subscribedIdentifier is not null) + { + RootRegisterService.Unsubscribe(_subscribedIdentifier); + } + + RootRegisterService.Subscribe(identifier, this); + _subscribedIdentifier = identifier; + } + + RenderContent(); + return Task.CompletedTask; + } + + internal void ContentUpdated(BootstrapBlazorRootContent? provider) + { + RenderContent(); + } + + private void RenderContent() + { + _renderHandle.Render(BuildRenderTree); + } + + /// + /// + /// + /// + private void BuildRenderTree(RenderTreeBuilder builder) + { + if (_subscribedIdentifier is not null) + { + foreach (var content in RootRegisterService.GetProviders(_subscribedIdentifier)) + { + builder.OpenComponent(0); + builder.SetKey(content); + builder.AddAttribute(1, BootstrapBlazorRootOutletContentRenderer.ContentParameterName, content.ChildContent ?? _emptyRenderFragment); + builder.CloseComponent(); + } + } + } + + /// + /// + /// + public void Dispose() + { + if (_subscribedIdentifier is not null) + { + RootRegisterService.Unsubscribe(_subscribedIdentifier); + } + GC.SuppressFinalize(this); + } + + internal sealed class BootstrapBlazorRootOutletContentRenderer : IComponent + { + public const string ContentParameterName = "content"; + + private RenderHandle _renderHandle; + + public void Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + public Task SetParametersAsync(ParameterView parameters) + { + var fragment = parameters.GetValueOrDefault(ContentParameterName)!; + _renderHandle.Render(fragment); + return Task.CompletedTask; + } + } +} diff --git a/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs b/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs index 1a5b0327d7f..34090ffded9 100644 --- a/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs +++ b/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs @@ -36,6 +36,9 @@ public static IServiceCollection AddBootstrapBlazor(this IServiceCollection serv services.TryAddSingleton(); services.TryAddSingleton(typeof(IDispatchService<>), typeof(DefaultDispatchService<>)); + // BootstrapBlazorRootRegisterService 服务 + services.AddScoped(); + // Html2Pdf 服务 services.TryAddSingleton(); diff --git a/src/BootstrapBlazor/Services/BootstrapBlazorRootRegisterService.cs b/src/BootstrapBlazor/Services/BootstrapBlazorRootRegisterService.cs new file mode 100644 index 00000000000..8c6bf5f0048 --- /dev/null +++ b/src/BootstrapBlazor/Services/BootstrapBlazorRootRegisterService.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// BootstrapBlazorRootRegisterService +/// +public class BootstrapBlazorRootRegisterService +{ + private readonly Dictionary _subscribersByIdentifier = []; + private readonly Dictionary> _providersByIdentifier = []; + + /// + /// add provider + /// + /// + /// + public void AddProvider(object identifier, BootstrapBlazorRootContent provider) + { + if (!_providersByIdentifier.TryGetValue(identifier, out var providers)) + { + providers = []; + _providersByIdentifier.Add(identifier, providers); + } + + providers.Add(provider); + } + + /// + /// remove provider + /// + /// + /// + public void RemoveProvider(object identifier, BootstrapBlazorRootContent provider) + { + if (!_providersByIdentifier.TryGetValue(identifier, out var providers)) + { + throw new InvalidOperationException($"There are no content providers with the given root ID '{identifier}'."); + } + + var index = providers.LastIndexOf(provider); + if (index < 0) + { + throw new InvalidOperationException($"The provider was not found in the providers list of the given root ID '{identifier}'."); + } + + providers.RemoveAt(index); + if (index == providers.Count) + { + // We just removed the most recently added provider, meaning we need to change + // the current content to that of second most recently added provider. + var contentProvider = GetCurrentProviderContentOrDefault(providers); + NotifyContentChangedForSubscriber(identifier, contentProvider); + } + } + + /// + /// get all providers by identifier + /// + /// + /// + public List GetProviders(object identifier) + { + _providersByIdentifier.TryGetValue(identifier, out var providers); + return providers ?? []; + } + + /// + /// subscribe + /// + /// + /// + public void Subscribe(object identifier, BootstrapBlazorRootOutlet subscriber) + { + if (_subscribersByIdentifier.ContainsKey(identifier)) + { + throw new InvalidOperationException($"There is already a subscriber to the content with the given root ID '{identifier}'."); + } + + _subscribersByIdentifier.Add(identifier, subscriber); + } + + /// + /// 取消订阅 + /// + /// + public void Unsubscribe(object identifier) + { + if (!_subscribersByIdentifier.Remove(identifier)) + { + throw new InvalidOperationException($"The subscriber with the given root ID '{identifier}' is already unsubscribed."); + } + } + + /// + /// Notify content provider changed + /// + /// + /// + public void NotifyContentProviderChanged(object identifier, BootstrapBlazorRootContent provider) + { + if (!_providersByIdentifier.TryGetValue(identifier, out var providers)) + { + throw new InvalidOperationException($"There are no content providers with the given root ID '{identifier}'."); + } + + // We only notify content changed for subscribers when the content of the + // most recently added provider changes. + if (providers.Count != 0 && providers[^1] == provider) + { + NotifyContentChangedForSubscriber(identifier, provider); + } + } + + private static BootstrapBlazorRootContent? GetCurrentProviderContentOrDefault(List providers) + => providers.Count != 0 + ? providers[^1] + : null; + + private void NotifyContentChangedForSubscriber(object identifier, BootstrapBlazorRootContent? provider) + { + if (_subscribersByIdentifier.TryGetValue(identifier, out var subscriber)) + { + subscriber.ContentUpdated(provider); + } + } +} diff --git a/test/UnitTest/Components/BootstrapBlazorRootOutletTest.cs b/test/UnitTest/Components/BootstrapBlazorRootOutletTest.cs new file mode 100644 index 00000000000..8f43414be1b --- /dev/null +++ b/test/UnitTest/Components/BootstrapBlazorRootOutletTest.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace UnitTest.Components; + +public class BootstrapBlazorRootOutletTest : BootstrapBlazorTestBase +{ + [Fact] + public async Task Content_Ok() + { + var cut = Context.RenderComponent(pb => + { + pb.Add(a => a.EnableErrorLogger, false); + pb.AddChildContent(pbc => + { + pbc.Add(a => a.RootName, "test"); + pbc.AddChildContent("test-content"); + }); + }); + cut.DoesNotContain("test-content"); + + var content = cut.FindComponent(); + content.SetParametersAndRender(pb => + { + pb.Add(a => a.RootName, null); + }); + cut.Contains("test-content"); + + content.SetParametersAndRender(pb => + { + pb.Add(a => a.RootId, new object()); + }); + cut.DoesNotContain("test-content"); + + content.SetParametersAndRender(pb => + { + pb.Add(a => a.RootId, null); + }); + cut.Contains("test-content"); + + content.SetParametersAndRender(pb => + { + pb.AddChildContent((RenderFragment)null!); + }); + + var exception = await Assert.ThrowsAsync(() => + { + content.SetParametersAndRender(pb => + { + pb.Add(a => a.RootName, "test"); + pb.Add(a => a.RootId, new object()); + }); + return Task.CompletedTask; + }); + Assert.Equal("BootstrapBlazorRootContent requires that 'RootName' and 'RootId' cannot both have non-null values.", exception.Message); + } + + [Fact] + public async Task Outlet_Ok() + { + var cut = Context.RenderComponent(pb => + { + pb.Add(a => a.RootId, new object()); + }); + Assert.Empty(cut.Markup); + + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.RootId, null); + pb.Add(a => a.RootName, "test"); + }); + Assert.Empty(cut.Markup); + + var exception = await Assert.ThrowsAsync(() => + { + cut.SetParametersAndRender(pb => + { + pb.Add(a => a.RootName, "test"); + pb.Add(a => a.RootId, new object()); + }); + return Task.CompletedTask; + }); + Assert.Equal("BootstrapBlazorRootOutlet requires that 'RootName' and 'RootId' cannot both have non-null values.", exception.Message); + } +} diff --git a/test/UnitTest/Services/BootstrapBlazorRootRegisterServiceTest.cs b/test/UnitTest/Services/BootstrapBlazorRootRegisterServiceTest.cs new file mode 100644 index 00000000000..59dabbf2377 --- /dev/null +++ b/test/UnitTest/Services/BootstrapBlazorRootRegisterServiceTest.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using System.Runtime.CompilerServices; + +namespace UnitTest.Services; + +public class BootstrapBlazorRootRegisterServiceTest +{ + [Fact] + public void Provider_Ok() + { + var service = new BootstrapBlazorRootRegisterService(); + var exception = Assert.ThrowsAny(() => service.RemoveProvider(new object(), new BootstrapBlazorRootContent())); + Assert.Equal("There are no content providers with the given root ID 'System.Object'.", exception.Message); + + var identifier = new object(); + service.AddProvider(identifier, new BootstrapBlazorRootContent()); + exception = Assert.ThrowsAny(() => service.RemoveProvider(identifier, new BootstrapBlazorRootContent())); + Assert.Equal("The provider was not found in the providers list of the given root ID 'System.Object'.", exception.Message); + } + + [Fact] + public void Subscribe_Ok() + { + var service = new BootstrapBlazorRootRegisterService(); + var identifier = new object(); + service.Subscribe(identifier, new BootstrapBlazorRootOutlet()); + var exception = Assert.ThrowsAny(() => service.Subscribe(identifier, new BootstrapBlazorRootOutlet())); + Assert.Equal("There is already a subscriber to the content with the given root ID 'System.Object'.", exception.Message); + + exception = Assert.ThrowsAny(() => service.Unsubscribe(new object())); + Assert.Equal("The subscriber with the given root ID 'System.Object' is already unsubscribed.", exception.Message); + } + + [Fact] + public void NotifyContentProviderChanged_Ok() + { + var service = new BootstrapBlazorRootRegisterService(); + var exception = Assert.ThrowsAny(() => service.NotifyContentProviderChanged(new object(), new BootstrapBlazorRootContent())); + Assert.Equal("There are no content providers with the given root ID 'System.Object'.", exception.Message); + + + var identifier = new object(); + service.AddProvider(identifier, new BootstrapBlazorRootContent()); + + var providers = service.GetProviders(identifier); + providers.Clear(); + service.NotifyContentProviderChanged(identifier, new BootstrapBlazorRootContent()); + } + + [Fact] + public void GetCurrentProviderContentOrDefault_Ok() + { + var service = new BootstrapBlazorRootRegisterService(); + Assert.Null(GetCurrentProviderContentOrDefault(service, [])); + + var provider = new BootstrapBlazorRootContent(); + service.AddProvider(new object(), provider); + Assert.Equal(provider, GetCurrentProviderContentOrDefault(service, [provider])); + } + + + [UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "GetCurrentProviderContentOrDefault")] + static extern BootstrapBlazorRootContent? GetCurrentProviderContentOrDefault(BootstrapBlazorRootRegisterService @this, List providers); + +}