From d0ea8bf7f8f56a253ff4a02e13a41873136dbe29 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 30 Jul 2019 14:10:25 +0100 Subject: [PATCH 01/36] Add LayoutView component --- src/Components/Components/src/LayoutView.cs | 77 +++++ .../Components/test/LayoutViewTest.cs | 317 ++++++++++++++++++ src/Components/Shared/test/TestRenderer.cs | 4 + 3 files changed, 398 insertions(+) create mode 100644 src/Components/Components/src/LayoutView.cs create mode 100644 src/Components/Components/test/LayoutViewTest.cs diff --git a/src/Components/Components/src/LayoutView.cs b/src/Components/Components/src/LayoutView.cs new file mode 100644 index 000000000000..bf0b9db78597 --- /dev/null +++ b/src/Components/Components/src/LayoutView.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// Displays the specified content inside the specified layout and any further + /// nested layouts. + /// + public class LayoutView : IComponent + { + private static RenderFragment EmptyRenderFragment = builder => { }; + + private RenderHandle _renderHandle; + + /// + /// Gets or sets the content to display. + /// + [Parameter] + public RenderFragment ChildContent { get; set; } + + /// + /// Gets or sets the type of the layout in which to display the content. + /// The type must implement and accept a parameter named . + /// + [Parameter] + public Type Layout { get; set; } + + /// + public void Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + /// + public Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + Render(); + return Task.CompletedTask; + } + + private void Render() + { + // In the middle goes the supplied content + var fragment = ChildContent ?? EmptyRenderFragment; + + // Then repeatedly wrap that in each layer of nested layout until we get + // to a layout that has no parent + var layoutType = Layout; + while (layoutType != null) + { + fragment = WrapInLayout(layoutType, fragment); + layoutType = GetParentLayoutType(layoutType); + } + + _renderHandle.Render(fragment); + } + + private static RenderFragment WrapInLayout(Type layoutType, RenderFragment bodyParam) + { + return builder => + { + builder.OpenComponent(0, layoutType); + builder.AddAttribute(1, LayoutComponentBase.BodyPropertyName, bodyParam); + builder.CloseComponent(); + }; + } + + private static Type GetParentLayoutType(Type type) + => type.GetCustomAttribute()?.LayoutType; + } +} diff --git a/src/Components/Components/test/LayoutViewTest.cs b/src/Components/Components/test/LayoutViewTest.cs new file mode 100644 index 000000000000..9dcc9941c4e3 --- /dev/null +++ b/src/Components/Components/test/LayoutViewTest.cs @@ -0,0 +1,317 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Test +{ + public class LayoutViewTest + { + private readonly TestRenderer _renderer; + private readonly LayoutView _layoutViewComponent; + private readonly int _layoutViewComponentId; + + public LayoutViewTest() + { + _renderer = new TestRenderer(); + _layoutViewComponent = new LayoutView(); + _layoutViewComponentId = _renderer.AssignRootComponentId(_layoutViewComponent); + } + + [Fact] + public void GivenNoParameters_RendersNothing() + { + // Arrange/Act + _renderer.Dispatcher.InvokeAsync(() => _layoutViewComponent.SetParametersAsync(ParameterView.Empty)); + var frames = _renderer.GetCurrentRenderTreeFrames(_layoutViewComponentId).AsEnumerable(); + + // Assert + Assert.Single(_renderer.Batches); + Assert.Empty(frames); + } + + [Fact] + public void GivenContentButNoLayout_RendersContent() + { + // Arrange/Act + _renderer.Dispatcher.InvokeAsync(() => _layoutViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(LayoutView.ChildContent), (RenderFragment)(builder => { + builder.AddContent(123, "Hello"); + builder.AddContent(456, "Goodbye"); + })} + }))); + var frames = _renderer.GetCurrentRenderTreeFrames(_layoutViewComponentId).AsEnumerable(); + + // Assert + Assert.Single(_renderer.Batches); + Assert.Collection(frames, + frame => AssertFrame.Text(frame, "Hello", 123), + frame => AssertFrame.Text(frame, "Goodbye", 456)); + } + + [Fact] + public void GivenLayoutButNoContent_RendersLayoutWithEmptyBody() + { + // Arrange/Act + _renderer.Dispatcher.InvokeAsync(() => _layoutViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(LayoutView.Layout), typeof(RootLayout) } + }))); + + // Assert + var batch = _renderer.Batches.Single(); + + var layoutViewFrames = _renderer.GetCurrentRenderTreeFrames(_layoutViewComponentId).AsEnumerable(); + Assert.Collection(layoutViewFrames, + frame => AssertFrame.Component(frame, subtreeLength: 2, sequence: 0), + frame => AssertFrame.Attribute(frame, nameof(LayoutComponentBase.Body), sequence: 1)); + + var rootLayoutComponentId = batch.GetComponentFrames().Single().ComponentId; + var rootLayoutFrames = _renderer.GetCurrentRenderTreeFrames(rootLayoutComponentId).AsEnumerable(); + Assert.Collection(rootLayoutFrames, + frame => AssertFrame.Text(frame, "RootLayout starts here", sequence: 0), + frame => AssertFrame.Region(frame, subtreeLength: 1), // i.e., empty region + frame => AssertFrame.Text(frame, "RootLayout ends here", sequence: 2)); + } + + [Fact] + public void RendersContentInsideLayout() + { + // Arrange/Act + _renderer.Dispatcher.InvokeAsync(() => _layoutViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(LayoutView.Layout), typeof(RootLayout) }, + { nameof(LayoutView.ChildContent), (RenderFragment)(builder => { + builder.AddContent(123, "Hello"); + builder.AddContent(456, "Goodbye"); + })} + }))); + + // Assert + var batch = _renderer.Batches.Single(); + + var layoutViewFrames = _renderer.GetCurrentRenderTreeFrames(_layoutViewComponentId).AsEnumerable(); + Assert.Collection(layoutViewFrames, + frame => AssertFrame.Component(frame, subtreeLength: 2, sequence: 0), + frame => AssertFrame.Attribute(frame, nameof(LayoutComponentBase.Body), sequence: 1)); + + var rootLayoutComponentId = batch.GetComponentFrames().Single().ComponentId; + var rootLayoutFrames = _renderer.GetCurrentRenderTreeFrames(rootLayoutComponentId).AsEnumerable(); + Assert.Collection(rootLayoutFrames, + frame => AssertFrame.Text(frame, "RootLayout starts here", sequence: 0), + frame => AssertFrame.Region(frame, subtreeLength: 3), + frame => AssertFrame.Text(frame, "Hello", sequence: 123), + frame => AssertFrame.Text(frame, "Goodbye", sequence: 456), + frame => AssertFrame.Text(frame, "RootLayout ends here", sequence: 2)); + } + + [Fact] + public void RendersContentInsideNestedLayout() + { + // Arrange/Act + _renderer.Dispatcher.InvokeAsync(() => _layoutViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(LayoutView.Layout), typeof(NestedLayout) }, + { nameof(LayoutView.ChildContent), (RenderFragment)(builder => { + builder.AddContent(123, "Hello"); + builder.AddContent(456, "Goodbye"); + })} + }))); + + // Assert + var batch = _renderer.Batches.Single(); + + var layoutViewFrames = _renderer.GetCurrentRenderTreeFrames(_layoutViewComponentId).AsEnumerable(); + Assert.Collection(layoutViewFrames, + frame => AssertFrame.Component(frame, subtreeLength: 2, sequence: 0), + frame => AssertFrame.Attribute(frame, nameof(LayoutComponentBase.Body), sequence: 1)); + + var rootLayoutComponentId = batch.GetComponentFrames().Single().ComponentId; + var rootLayoutFrames = _renderer.GetCurrentRenderTreeFrames(rootLayoutComponentId).AsEnumerable(); + Assert.Collection(rootLayoutFrames, + frame => AssertFrame.Text(frame, "RootLayout starts here", sequence: 0), + frame => AssertFrame.Region(frame, subtreeLength: 3, sequence: 1), + frame => AssertFrame.Component(frame, subtreeLength: 2, sequence: 0), + frame => AssertFrame.Attribute(frame, nameof(LayoutComponentBase.Body), sequence: 1), + frame => AssertFrame.Text(frame, "RootLayout ends here", sequence: 2)); + + var nestedLayoutComponentId = batch.GetComponentFrames().Single().ComponentId; + var nestedLayoutFrames = _renderer.GetCurrentRenderTreeFrames(nestedLayoutComponentId).AsEnumerable(); + Assert.Collection(nestedLayoutFrames, + frame => AssertFrame.Text(frame, "NestedLayout starts here", sequence: 0), + frame => AssertFrame.Region(frame, subtreeLength: 3, sequence: 1), + frame => AssertFrame.Text(frame, "Hello", sequence: 123), + frame => AssertFrame.Text(frame, "Goodbye", sequence: 456), + frame => AssertFrame.Text(frame, "NestedLayout ends here", sequence: 2)); + } + + [Fact] + public void CanChangeContentWithSameLayout() + { + // Arrange + _renderer.Dispatcher.InvokeAsync(() => _layoutViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(LayoutView.Layout), typeof(NestedLayout) }, + { nameof(LayoutView.ChildContent), (RenderFragment)(builder => { + builder.AddContent(0, "Initial content"); + })} + }))); + + // Act + _renderer.Dispatcher.InvokeAsync(() => _layoutViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(LayoutView.Layout), typeof(NestedLayout) }, + { nameof(LayoutView.ChildContent), (RenderFragment)(builder => { + builder.AddContent(0, "Changed content"); + })} + }))); + + // Assert + Assert.Equal(2, _renderer.Batches.Count); + var batch = _renderer.Batches[1]; + Assert.Equal(0, batch.DisposedComponentIDs.Count); + Assert.Collection(batch.DiffsInOrder, + diff => Assert.Empty(diff.Edits), // LayoutView rerendered, but with no changes + diff => Assert.Empty(diff.Edits), // RootLayout rerendered, but with no changes + diff => + { + // NestedLayout rerendered, patching content in place + Assert.Collection(diff.Edits, edit => + { + Assert.Equal(RenderTreeEditType.UpdateText, edit.Type); + Assert.Equal(1, edit.SiblingIndex); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "Changed content", + sequence: 0); + }); + }); + } + + [Fact] + public void CanChangeLayout() + { + // Arrange + _renderer.Dispatcher.InvokeAsync(() => _layoutViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(LayoutView.Layout), typeof(NestedLayout) }, + { nameof(LayoutView.ChildContent), (RenderFragment)(builder => { + builder.AddContent(0, "Some content"); + })} + }))); + + // Act + _renderer.Dispatcher.InvokeAsync(() => _layoutViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(LayoutView.Layout), typeof(OtherNestedLayout) }, + }))); + + // Assert + Assert.Equal(2, _renderer.Batches.Count); + var batch = _renderer.Batches[1]; + Assert.Equal(1, batch.DisposedComponentIDs.Count); // Disposes NestedLayout + Assert.Collection(batch.DiffsInOrder, + diff => Assert.Empty(diff.Edits), // LayoutView rerendered, but with no changes + diff => + { + // RootLayout rerendered, changing child + Assert.Collection(diff.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.RemoveFrame, edit.Type); + Assert.Equal(1, edit.SiblingIndex); + }, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + Assert.Equal(1, edit.SiblingIndex); + AssertFrame.Component( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + sequence: 0); + }); + }, + diff => + { + // Inserts new OtherNestedLayout + Assert.Collection(diff.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + Assert.Equal(0, edit.SiblingIndex); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "OtherNestedLayout starts here"); + }, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + Assert.Equal(1, edit.SiblingIndex); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "Some content"); + }, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + Assert.Equal(2, edit.SiblingIndex); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "OtherNestedLayout ends here"); + }); + }); + } + + private class RootLayout : AutoRenderComponent + { + [Parameter] + public RenderFragment Body { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (Body == null) + { + // Prove that we don't expect layouts to tolerate null values for Body + throw new InvalidOperationException("Got a null body when not expecting it"); + } + + builder.AddContent(0, "RootLayout starts here"); + builder.AddContent(1, Body); + builder.AddContent(2, "RootLayout ends here"); + } + } + + [Layout(typeof(RootLayout))] + private class NestedLayout : AutoRenderComponent + { + [Parameter] + public RenderFragment Body { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, "NestedLayout starts here"); + builder.AddContent(1, Body); + builder.AddContent(2, "NestedLayout ends here"); + } + } + + [Layout(typeof(RootLayout))] + private class OtherNestedLayout : AutoRenderComponent + { + [Parameter] + public RenderFragment Body { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, "OtherNestedLayout starts here"); + builder.AddContent(1, Body); + builder.AddContent(2, "OtherNestedLayout ends here"); + } + } + } +} diff --git a/src/Components/Shared/test/TestRenderer.cs b/src/Components/Shared/test/TestRenderer.cs index d5ce07bf4b06..135a5cbda278 100644 --- a/src/Components/Shared/test/TestRenderer.cs +++ b/src/Components/Shared/test/TestRenderer.cs @@ -7,6 +7,7 @@ using System.Runtime.ExceptionServices; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -48,6 +49,9 @@ public TestRenderer(IServiceProvider serviceProvider) : base(serviceProvider, Nu public new int AssignRootComponentId(IComponent component) => base.AssignRootComponentId(component); + public new ArrayRange GetCurrentRenderTreeFrames(int componentId) + => base.GetCurrentRenderTreeFrames(componentId); + public void RenderRootComponent(int componentId, ParameterView? parameters = default) { var task = Dispatcher.InvokeAsync(() => base.RenderRootComponentAsync(componentId, parameters ?? ParameterView.Empty)); From edba2f82503007cb181318e7dfe1a6b54b9631c9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 30 Jul 2019 15:40:21 +0100 Subject: [PATCH 02/36] Add RouteView component --- .../Components/src/ComponentRouteData.cs | 36 +++ src/Components/Components/src/RouteView.cs | 86 ++++++++ .../Components/test/RouteViewTest.cs | 208 ++++++++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 src/Components/Components/src/ComponentRouteData.cs create mode 100644 src/Components/Components/src/RouteView.cs create mode 100644 src/Components/Components/test/RouteViewTest.cs diff --git a/src/Components/Components/src/ComponentRouteData.cs b/src/Components/Components/src/ComponentRouteData.cs new file mode 100644 index 000000000000..67719a092bee --- /dev/null +++ b/src/Components/Components/src/ComponentRouteData.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// Describes information determined during routing that specifies + /// the page to be displayed. + /// + public sealed class ComponentRouteData + { + /// + /// Constructs an instance of . + /// + /// The type of the page component matching the route. + /// The parameters for the page component matching the route. + public ComponentRouteData(Type pageComponentType, IDictionary pageParameters) + { + PageComponentType = pageComponentType ?? throw new ArgumentNullException(nameof(pageComponentType)); + PageParameters = pageParameters ?? throw new ArgumentNullException(nameof(pageParameters)); + } + + /// + /// Gets the type of the page component matching the route. + /// + public Type PageComponentType { get; } + + /// + /// Gets the parameters for the page component matching the route. + /// + public IDictionary PageParameters { get; } + } +} diff --git a/src/Components/Components/src/RouteView.cs b/src/Components/Components/src/RouteView.cs new file mode 100644 index 000000000000..af8f12652704 --- /dev/null +++ b/src/Components/Components/src/RouteView.cs @@ -0,0 +1,86 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// Displays the specified page component, rendering it inside its layout + /// and any further nested layouts. + /// + public class RouteView : IComponent + { + private readonly RenderFragment _renderDelegate; + private readonly RenderFragment _renderPageWithParametersDelegate; + private RenderHandle _renderHandle; + + /// + /// Gets or sets the route data. This determines the page that will be + /// displayed and the parameter values that will be supplied to the page. + /// + [Parameter] + public ComponentRouteData RouteData { get; set; } + + /// + /// Gets or sets the type of a layout to be used if the page does not + /// declare any layout. If specified, the type must implement + /// and accept a parameter named . + /// + [Parameter] + public Type DefaultLayout { get; set; } + + public RouteView() + { + // Cache the delegate instances + _renderDelegate = Render; + _renderPageWithParametersDelegate = RenderPageWithParameters; + } + + /// + public void Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + /// + public Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + if (RouteData == null) + { + throw new InvalidOperationException($"The {nameof(RouteView)} component requires a non-null value for the parameter {nameof(RouteData)}."); + } + + _renderHandle.Render(_renderDelegate); + return Task.CompletedTask; + } + + private void Render(RenderTreeBuilder builder) + { + var pageLayoutType = RouteData.PageComponentType.GetCustomAttribute()?.LayoutType + ?? DefaultLayout; + + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(LayoutView.Layout), pageLayoutType); + builder.AddAttribute(2, nameof(LayoutView.ChildContent), _renderPageWithParametersDelegate); + builder.CloseComponent(); + } + + private void RenderPageWithParameters(RenderTreeBuilder builder) + { + builder.OpenComponent(0, RouteData.PageComponentType); + + foreach (var kvp in RouteData.PageParameters) + { + builder.AddAttribute(1, kvp.Key, kvp.Value); + } + + builder.CloseComponent(); + } + } +} diff --git a/src/Components/Components/test/RouteViewTest.cs b/src/Components/Components/test/RouteViewTest.cs new file mode 100644 index 000000000000..dbda5d13dc7a --- /dev/null +++ b/src/Components/Components/test/RouteViewTest.cs @@ -0,0 +1,208 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Test +{ + public class RouteViewTest + { + private readonly TestRenderer _renderer; + private readonly RouteView _routeViewComponent; + private readonly int _routeViewComponentId; + + public RouteViewTest() + { + _renderer = new TestRenderer(); + _routeViewComponent = new RouteView(); + _routeViewComponentId = _renderer.AssignRootComponentId(_routeViewComponent); + } + + [Fact] + public void ThrowsIfNoRouteDataSupplied() + { + var ex = Assert.Throws(() => + { + // Throws synchronously, so no need to await + _ = _routeViewComponent.SetParametersAsync(ParameterView.Empty); + }); + + + Assert.Equal($"The {nameof(RouteView)} component requires a non-null value for the parameter {nameof(RouteView.RouteData)}.", ex.Message); + } + + [Fact] + public void RendersPageInsideLayoutView() + { + // Arrange + var routeParams = new Dictionary + { + { nameof(ComponentWithLayout.Message), "Test message" } + }; + var routeData = new ComponentRouteData(typeof(ComponentWithLayout), routeParams); + + // Act + _renderer.Dispatcher.InvokeAsync(() => _routeViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(RouteView.RouteData), routeData }, + }))); + + // Assert: RouteView renders LayoutView + var batch = _renderer.Batches.Single(); + var routeViewFrames = _renderer.GetCurrentRenderTreeFrames(_routeViewComponentId).AsEnumerable(); + Assert.Collection(routeViewFrames, + frame => AssertFrame.Component(frame, subtreeLength: 3, sequence: 0), + frame => AssertFrame.Attribute(frame, nameof(LayoutView.Layout), (object)typeof(TestLayout), sequence: 1), + frame => AssertFrame.Attribute(frame, nameof(LayoutView.ChildContent), sequence: 2)); + + // Assert: LayoutView renders TestLayout + var layoutViewComponentId = batch.GetComponentFrames().Single().ComponentId; + var layoutViewFrames = _renderer.GetCurrentRenderTreeFrames(layoutViewComponentId).AsEnumerable(); + Assert.Collection(layoutViewFrames, + frame => AssertFrame.Component(frame, subtreeLength: 2, sequence: 0), + frame => AssertFrame.Attribute(frame, nameof(LayoutComponentBase.Body), sequence: 1)); + + // Assert: TestLayout renders page + var testLayoutComponentId = batch.GetComponentFrames().Single().ComponentId; + var testLayoutFrames = _renderer.GetCurrentRenderTreeFrames(testLayoutComponentId).AsEnumerable(); + Assert.Collection(testLayoutFrames, + frame => AssertFrame.Text(frame, "Layout starts here", sequence: 0), + frame => AssertFrame.Region(frame, subtreeLength: 3), + frame => AssertFrame.Component(frame, sequence: 0, subtreeLength: 2), + frame => AssertFrame.Attribute(frame, nameof(ComponentWithLayout.Message), "Test message", sequence: 1), + frame => AssertFrame.Text(frame, "Layout ends here", sequence: 2)); + + // Assert: page itself is rendered, having received parameters from the original route data + var pageComponentId = batch.GetComponentFrames().Single().ComponentId; + var pageFrames = _renderer.GetCurrentRenderTreeFrames(pageComponentId).AsEnumerable(); + Assert.Collection(pageFrames, + frame => AssertFrame.Text(frame, "Hello from the page with message 'Test message'", sequence: 0)); + + // Assert: nothing else was rendered + Assert.Equal(4, batch.DiffsInOrder.Count); + } + + [Fact] + public void UsesDefaultLayoutIfNoneSetOnPage() + { + // Arrange + var routeParams = new Dictionary(); + var routeData = new ComponentRouteData(typeof(ComponentWithoutLayout), routeParams); + + // Act + _renderer.Dispatcher.InvokeAsync(() => _routeViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(RouteView.RouteData), routeData }, + { nameof(RouteView.DefaultLayout), typeof(OtherLayout) }, + }))); + + // Assert: uses default layout + // Not asserting about what else gets rendered as that's covered by other tests + var batch = _renderer.Batches.Single(); + var routeViewFrames = _renderer.GetCurrentRenderTreeFrames(_routeViewComponentId).AsEnumerable(); + Assert.Collection(routeViewFrames, + frame => AssertFrame.Component(frame, subtreeLength: 3, sequence: 0), + frame => AssertFrame.Attribute(frame, nameof(LayoutView.Layout), (object)typeof(OtherLayout), sequence: 1), + frame => AssertFrame.Attribute(frame, nameof(LayoutView.ChildContent), sequence: 2)); + } + + [Fact] + public void UsesNoLayoutIfNoneSetOnPageAndNoDefaultSet() + { + // Arrange + var routeParams = new Dictionary(); + var routeData = new ComponentRouteData(typeof(ComponentWithoutLayout), routeParams); + + // Act + _renderer.Dispatcher.InvokeAsync(() => _routeViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(RouteView.RouteData), routeData }, + }))); + + // Assert: uses no layout + // Not asserting about what else gets rendered as that's covered by other tests + var batch = _renderer.Batches.Single(); + var routeViewFrames = _renderer.GetCurrentRenderTreeFrames(_routeViewComponentId).AsEnumerable(); + Assert.Collection(routeViewFrames, + frame => AssertFrame.Component(frame, subtreeLength: 3, sequence: 0), + frame => AssertFrame.Attribute(frame, nameof(LayoutView.Layout), (object)null, sequence: 1), + frame => AssertFrame.Attribute(frame, nameof(LayoutView.ChildContent), sequence: 2)); + } + + [Fact] + public void PageLayoutSupersedesDefaultLayout() + { + // Arrange + var routeParams = new Dictionary(); + var routeData = new ComponentRouteData(typeof(ComponentWithLayout), routeParams); + + // Act + _renderer.Dispatcher.InvokeAsync(() => _routeViewComponent.SetParametersAsync(ParameterView.FromDictionary(new Dictionary + { + { nameof(RouteView.RouteData), routeData }, + { nameof(RouteView.DefaultLayout), typeof(OtherLayout) }, + }))); + + // Assert: uses layout specified by page + // Not asserting about what else gets rendered as that's covered by other tests + var batch = _renderer.Batches.Single(); + var routeViewFrames = _renderer.GetCurrentRenderTreeFrames(_routeViewComponentId).AsEnumerable(); + Assert.Collection(routeViewFrames, + frame => AssertFrame.Component(frame, subtreeLength: 3, sequence: 0), + frame => AssertFrame.Attribute(frame, nameof(LayoutView.Layout), (object)typeof(TestLayout), sequence: 1), + frame => AssertFrame.Attribute(frame, nameof(LayoutView.ChildContent), sequence: 2)); + } + + private class ComponentWithoutLayout : AutoRenderComponent + { + [Parameter] public string Message { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, $"Hello from the page with message '{Message}'"); + } + } + + [Layout(typeof(TestLayout))] + private class ComponentWithLayout : AutoRenderComponent + { + [Parameter] public string Message { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, $"Hello from the page with message '{Message}'"); + } + } + + private class TestLayout : AutoRenderComponent + { + [Parameter] + public RenderFragment Body { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, "Layout starts here"); + builder.AddContent(1, Body); + builder.AddContent(2, "Layout ends here"); + } + } + + private class OtherLayout : AutoRenderComponent + { + [Parameter] + public RenderFragment Body { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, "OtherLayout starts here"); + builder.AddContent(1, Body); + builder.AddContent(2, "OtherLayout ends here"); + } + } + } +} From 29687d86b3c3d8f4fa98d9eb30a66f044c174ce2 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 30 Jul 2019 16:05:25 +0100 Subject: [PATCH 03/36] Update Router to new factoring --- .../Components/src/Routing/Router.cs | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index 96eca173227c..b36b4626d6f6 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -2,17 +2,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components.Routing { /// - /// A component that displays whichever other component corresponds to the - /// current navigation location. + /// A component that supplies route data corresponding to the current navigation state. /// public class Router : IComponent, IHandleAfterRender, IDisposable { @@ -39,19 +36,14 @@ public class Router : IComponent, IHandleAfterRender, IDisposable [Parameter] public Assembly AppAssembly { get; set; } /// - /// Gets or sets the type of the component that should be used as a fallback when no match is found for the requested route. + /// Gets or sets the content to display when no match is found for the requested route. /// [Parameter] public RenderFragment NotFound { get; set; } /// - /// The content that will be displayed if the user is not authorized. + /// Gets or sets the content to display when a match is found for the requested route. /// - [Parameter] public RenderFragment NotAuthorized { get; set; } - - /// - /// The content that will be displayed while asynchronous authorization is in progress. - /// - [Parameter] public RenderFragment Authorizing { get; set; } + [Parameter] public RenderFragment Found { get; set; } private RouteTable Routes { get; set; } @@ -69,6 +61,22 @@ public void Attach(RenderHandle renderHandle) public Task SetParametersAsync(ParameterView parameters) { parameters.SetParameterProperties(this); + + // Found content is mandatory, because even though we could use something like as a + // reasonable default, if it's not declared explicitly in the template then people will have no way + // to discover how to customize this (e.g., to add authorization). + if (Found == null) + { + throw new InvalidOperationException($"The {nameof(Router)} component requires a value for the parameter {nameof(Found)}."); + } + + // NotFound content is mandatory, because even though we could display a default message like "Not found", + // it has to be specified explicitly so that it can also be wrapped in a specific layout + if (NotFound == null) + { + throw new InvalidOperationException($"The {nameof(Router)} component requires a value for the parameter {nameof(NotFound)}."); + } + Routes = RouteTableFactory.Create(AppAssembly); Refresh(isNavigationIntercepted: false); return Task.CompletedTask; @@ -80,7 +88,7 @@ public void Dispose() NavigationManager.LocationChanged -= OnLocationChanged; } - private string StringUntilAny(string str, char[] chars) + private static string StringUntilAny(string str, char[] chars) { var firstIndex = str.IndexOfAny(chars); return firstIndex < 0 @@ -88,17 +96,6 @@ private string StringUntilAny(string str, char[] chars) : str.Substring(0, firstIndex); } - /// - protected virtual void Render(RenderTreeBuilder builder, Type handler, IDictionary parameters) - { - builder.OpenComponent(0, typeof(PageDisplay)); - builder.AddAttribute(1, nameof(PageDisplay.Page), handler); - builder.AddAttribute(2, nameof(PageDisplay.PageParameters), parameters); - builder.AddAttribute(3, nameof(PageDisplay.NotAuthorized), NotAuthorized); - builder.AddAttribute(4, nameof(PageDisplay.Authorizing), Authorizing); - builder.CloseComponent(); - } - private void Refresh(bool isNavigationIntercepted) { var locationPath = NavigationManager.ToBaseRelativePath(_locationAbsolute); @@ -116,16 +113,17 @@ private void Refresh(bool isNavigationIntercepted) Log.NavigatingToComponent(_logger, context.Handler, locationPath, _baseUri); - _renderHandle.Render(builder => Render(builder, context.Handler, context.Parameters)); + var routeData = new ComponentRouteData(context.Handler, context.Parameters); + _renderHandle.Render(Found(routeData)); } else { - if (!isNavigationIntercepted && NotFound != null) + if (!isNavigationIntercepted) { Log.DisplayingNotFound(_logger, locationPath, _baseUri); // We did not find a Component that matches the route. - // Only show the NotFound if the application developer programatically got us here i.e we did not + // Only show the NotFound content if the application developer programatically got us here i.e we did not // intercept the navigation. In all other cases, force a browser navigation since this could be non-Blazor content. _renderHandle.Render(NotFound); } From 5bc856d22ad9d4f29089dbd29e14a37bcfe8a306 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 30 Jul 2019 16:17:12 +0100 Subject: [PATCH 04/36] Mark route parameter dictionaries as readonly --- src/Components/Components/src/ComponentRouteData.cs | 4 ++-- src/Components/Components/src/Routing/RouteContext.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Components/Components/src/ComponentRouteData.cs b/src/Components/Components/src/ComponentRouteData.cs index 67719a092bee..1159eaecce23 100644 --- a/src/Components/Components/src/ComponentRouteData.cs +++ b/src/Components/Components/src/ComponentRouteData.cs @@ -17,7 +17,7 @@ public sealed class ComponentRouteData /// /// The type of the page component matching the route. /// The parameters for the page component matching the route. - public ComponentRouteData(Type pageComponentType, IDictionary pageParameters) + public ComponentRouteData(Type pageComponentType, IReadOnlyDictionary pageParameters) { PageComponentType = pageComponentType ?? throw new ArgumentNullException(nameof(pageComponentType)); PageParameters = pageParameters ?? throw new ArgumentNullException(nameof(pageParameters)); @@ -31,6 +31,6 @@ public ComponentRouteData(Type pageComponentType, IDictionary pa /// /// Gets the parameters for the page component matching the route. /// - public IDictionary PageParameters { get; } + public IReadOnlyDictionary PageParameters { get; } } } diff --git a/src/Components/Components/src/Routing/RouteContext.cs b/src/Components/Components/src/Routing/RouteContext.cs index 7de5f3c61552..7061e9be4105 100644 --- a/src/Components/Components/src/Routing/RouteContext.cs +++ b/src/Components/Components/src/Routing/RouteContext.cs @@ -26,6 +26,6 @@ public RouteContext(string path) public Type Handler { get; set; } - public IDictionary Parameters { get; set; } + public IReadOnlyDictionary Parameters { get; set; } } } From f6868407aabdae95db827e4e1b92feb076c18cd6 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 30 Jul 2019 16:19:13 +0100 Subject: [PATCH 05/36] Update ComponentsApp to use new APIs --- src/Components/Components/src/Routing/Router.cs | 8 +++++++- .../test/testassets/ComponentsApp.App/App.razor | 16 +++++++++++----- .../ComponentsApp.App/Pages/_Imports.razor | 1 - 3 files changed, 18 insertions(+), 7 deletions(-) delete mode 100644 src/Components/test/testassets/ComponentsApp.App/Pages/_Imports.razor diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index b36b4626d6f6..d81ba6501c3f 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -14,6 +16,8 @@ namespace Microsoft.AspNetCore.Components.Routing public class Router : IComponent, IHandleAfterRender, IDisposable { static readonly char[] _queryOrHashStartChar = new[] { '?', '#' }; + static readonly ReadOnlyDictionary _emptyParametersDictionary + = new ReadOnlyDictionary(new Dictionary()); RenderHandle _renderHandle; string _baseUri; @@ -113,7 +117,9 @@ private void Refresh(bool isNavigationIntercepted) Log.NavigatingToComponent(_logger, context.Handler, locationPath, _baseUri); - var routeData = new ComponentRouteData(context.Handler, context.Parameters); + var routeData = new ComponentRouteData( + context.Handler, + context.Parameters ?? _emptyParametersDictionary); _renderHandle.Render(Found(routeData)); } else diff --git a/src/Components/test/testassets/ComponentsApp.App/App.razor b/src/Components/test/testassets/ComponentsApp.App/App.razor index a6a7873965d9..1339a7b97616 100644 --- a/src/Components/test/testassets/ComponentsApp.App/App.razor +++ b/src/Components/test/testassets/ComponentsApp.App/App.razor @@ -1,10 +1,16 @@ @using Microsoft.AspNetCore.Components; - - + + + + + + +

Not found

+ Sorry, there's nothing at this address. +
+
+
@code{ diff --git a/src/Components/test/testassets/ComponentsApp.App/Pages/_Imports.razor b/src/Components/test/testassets/ComponentsApp.App/Pages/_Imports.razor deleted file mode 100644 index 5e11c2a20cec..000000000000 --- a/src/Components/test/testassets/ComponentsApp.App/Pages/_Imports.razor +++ /dev/null @@ -1 +0,0 @@ -@layout MainLayout From 4c896a52aa457be000c786ed334e8d6a6f337c76 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 30 Jul 2019 16:22:35 +0100 Subject: [PATCH 06/36] Update client-side StandaloneApp to use new APIs --- .../Blazor/testassets/StandaloneApp/App.razor | 16 +++++++++++----- .../StandaloneApp/Pages/_Imports.razor | 1 - 2 files changed, 11 insertions(+), 6 deletions(-) delete mode 100644 src/Components/Blazor/testassets/StandaloneApp/Pages/_Imports.razor diff --git a/src/Components/Blazor/testassets/StandaloneApp/App.razor b/src/Components/Blazor/testassets/StandaloneApp/App.razor index 4b8a0ffa42de..f5cb10de0316 100644 --- a/src/Components/Blazor/testassets/StandaloneApp/App.razor +++ b/src/Components/Blazor/testassets/StandaloneApp/App.razor @@ -1,5 +1,11 @@ - - + + + + + + +

Not found

+ Sorry, there's nothing at this address. +
+
+
diff --git a/src/Components/Blazor/testassets/StandaloneApp/Pages/_Imports.razor b/src/Components/Blazor/testassets/StandaloneApp/Pages/_Imports.razor deleted file mode 100644 index 5e11c2a20cec..000000000000 --- a/src/Components/Blazor/testassets/StandaloneApp/Pages/_Imports.razor +++ /dev/null @@ -1 +0,0 @@ -@layout MainLayout From 5a6a8965c8779a12e3594e4e0855e084ede0e02a Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 30 Jul 2019 16:24:01 +0100 Subject: [PATCH 07/36] Renames for clarity --- src/Components/Blazor/testassets/StandaloneApp/App.razor | 4 ++-- src/Components/test/testassets/ComponentsApp.App/App.razor | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Components/Blazor/testassets/StandaloneApp/App.razor b/src/Components/Blazor/testassets/StandaloneApp/App.razor index f5cb10de0316..8bc607a82cd8 100644 --- a/src/Components/Blazor/testassets/StandaloneApp/App.razor +++ b/src/Components/Blazor/testassets/StandaloneApp/App.razor @@ -1,6 +1,6 @@ - + - + diff --git a/src/Components/test/testassets/ComponentsApp.App/App.razor b/src/Components/test/testassets/ComponentsApp.App/App.razor index 1339a7b97616..53c3a2ea8173 100644 --- a/src/Components/test/testassets/ComponentsApp.App/App.razor +++ b/src/Components/test/testassets/ComponentsApp.App/App.razor @@ -1,8 +1,8 @@ @using Microsoft.AspNetCore.Components; - + - + From d095e99e643829ad94bc841baef100946d6a5a40 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 31 Jul 2019 09:47:28 +0100 Subject: [PATCH 08/36] Convert router E2E test to use new APIs --- .../testassets/BasicTestApp/AuthTest/AuthRouter.razor | 10 ++++++---- .../testassets/BasicTestApp/RouterTest/Default.razor | 1 - .../testassets/BasicTestApp/RouterTest/Links.razor | 1 - .../testassets/BasicTestApp/RouterTest/LongPage1.razor | 2 -- .../testassets/BasicTestApp/RouterTest/LongPage2.razor | 2 -- .../testassets/BasicTestApp/RouterTest/Other.razor | 1 - .../BasicTestApp/RouterTest/TestRouter.razor | 5 ++++- .../BasicTestApp/RouterTest/TestRouterLayout.razor | 6 ++++++ .../BasicTestApp/RouterTest/WithParameters.razor | 1 - 9 files changed, 16 insertions(+), 13 deletions(-) create mode 100644 src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterLayout.razor diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor index 5ec7bbe529c9..82e6469938bb 100644 --- a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor +++ b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor @@ -9,13 +9,15 @@ *@ - - Authorizing... - + + @* This will be put back later when implemented + Authorizing... +
Sorry, @(context.User.Identity.Name ?? "anonymous"), you're not authorized.
-
+ + *@
diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/Default.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/Default.razor index 09c74d19ec3c..8a45ef6a395e 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/Default.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/Default.razor @@ -1,4 +1,3 @@ @page "/" @page "/Default.html"
This is the default page.
- diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/Links.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/Links.razor index c5a562638216..cf5031eb116b 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/Links.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/Links.razor @@ -38,4 +38,3 @@ Not a component Cannot route to me - diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/LongPage1.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/LongPage1.razor index 208f0ebc1fdf..1a11a3dd3f3f 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/LongPage1.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/LongPage1.razor @@ -9,5 +9,3 @@ - - diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/LongPage2.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/LongPage2.razor index 8a2e73383441..95c3766264da 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/LongPage2.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/LongPage2.razor @@ -4,5 +4,3 @@
Scroll past me to find the links
- - diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/Other.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/Other.razor index 34e219cccc56..3be36fce3b82 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/Other.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/Other.razor @@ -1,3 +1,2 @@ @page "/Other"
This is another page.
- diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouter.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouter.razor index 3dbc49bce3ed..fdf000ca81fe 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouter.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouter.razor @@ -1,5 +1,8 @@ @using Microsoft.AspNetCore.Components.Routing - + + + +
Oops, that component wasn't found!
diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterLayout.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterLayout.razor new file mode 100644 index 000000000000..5e00519bb921 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterLayout.razor @@ -0,0 +1,6 @@ +@using Microsoft.AspNetCore.Components +@inherits LayoutComponentBase + +@Body + + diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/WithParameters.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/WithParameters.razor index a353a87c95fc..4997af9ec170 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/WithParameters.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/WithParameters.razor @@ -1,7 +1,6 @@ @page "/WithParameters/Name/{firstName}" @page "/WithParameters/Name/{firstName}/LastName/{lastName}"
Your full name is @FirstName @LastName.
- @code { From 59de8be7ef86c0aeb3198fef71846749b3524d8e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 31 Jul 2019 10:46:47 +0100 Subject: [PATCH 09/36] Simplistic implementation of AuthorizeViewCore which I don't like. Update E2E tests. --- .../src/Auth/AuthorizeRouteView.razor | 40 +++++++++++++++++++ .../src/Auth/AuthorizeRouteViewCore.cs | 20 ++++++++++ .../Components/src/Auth/AuthorizeViewCore.cs | 6 +++ src/Components/test/E2ETest/Tests/AuthTest.cs | 21 ++++++++++ .../BasicTestApp/AuthTest/AuthRouter.razor | 30 +++++++------- .../{Links.razor => AuthRouterLayout.razor} | 5 +++ 6 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 src/Components/Components/src/Auth/AuthorizeRouteView.razor create mode 100644 src/Components/Components/src/Auth/AuthorizeRouteViewCore.cs rename src/Components/test/testassets/BasicTestApp/AuthTest/{Links.razor => AuthRouterLayout.razor} (93%) diff --git a/src/Components/Components/src/Auth/AuthorizeRouteView.razor b/src/Components/Components/src/Auth/AuthorizeRouteView.razor new file mode 100644 index 000000000000..a36b0a5284e3 --- /dev/null +++ b/src/Components/Components/src/Auth/AuthorizeRouteView.razor @@ -0,0 +1,40 @@ +@namespace Microsoft.AspNetCore.Components + @* TODO: Make this internal, or inline it here *@ + @* TODO: Don't want to expose this, so find some way of avoiding it, e.g. by implementing this in C# *@ + + + + + + + + + + + + +@code { + /// + /// Gets or sets the route data. + /// + [Parameter] + public ComponentRouteData RouteData { get; set; } + + /// + /// Gets or sets the type of a layout to be used if the page does not + /// declare any layout. If specified, the type must implement + /// and accept a parameter named . + /// + [Parameter] + public Type DefaultLayout { get; set; } + + /// + /// The content that will be displayed if the user is not authorized. + /// + [Parameter] public RenderFragment NotAuthorized { get; set; } + + /// + /// The content that will be displayed while asynchronous authorization is in progress. + /// + [Parameter] public RenderFragment Authorizing { get; set; } +} diff --git a/src/Components/Components/src/Auth/AuthorizeRouteViewCore.cs b/src/Components/Components/src/Auth/AuthorizeRouteViewCore.cs new file mode 100644 index 000000000000..7f5a209171a2 --- /dev/null +++ b/src/Components/Components/src/Auth/AuthorizeRouteViewCore.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components.Auth; + +namespace Microsoft.AspNetCore.Components +{ + public class AuthorizeRouteViewCore : AuthorizeViewCore + { + /// + /// Gets or sets the route data. + /// + [Parameter] + public ComponentRouteData RouteData { get; set; } + + protected override IAuthorizeData[] GetAuthorizeData() + => AttributeAuthorizeDataCache.GetAuthorizeDataForType(RouteData.PageComponentType); + } +} diff --git a/src/Components/Components/src/Auth/AuthorizeViewCore.cs b/src/Components/Components/src/Auth/AuthorizeViewCore.cs index 8c60078184f8..28162d0d6ed0 100644 --- a/src/Components/Components/src/Auth/AuthorizeViewCore.cs +++ b/src/Components/Components/src/Auth/AuthorizeViewCore.cs @@ -102,6 +102,12 @@ protected override async Task OnParametersSetAsync() private async Task IsAuthorizedAsync(ClaimsPrincipal user) { var authorizeData = GetAuthorizeData(); + if (authorizeData == null) + { + // No authorization applies, so no need to consult the authorization service + return true; + } + EnsureNoAuthenticationSchemeSpecified(authorizeData); var policy = await AuthorizationPolicy.CombineAsync( diff --git a/src/Components/test/E2ETest/Tests/AuthTest.cs b/src/Components/test/E2ETest/Tests/AuthTest.cs index deaa6bfb640b..655505078a48 100644 --- a/src/Components/test/E2ETest/Tests/AuthTest.cs +++ b/src/Components/test/E2ETest/Tests/AuthTest.cs @@ -44,6 +44,7 @@ public void CascadingAuthenticationState_Unauthenticated() Browser.Equal("False", () => appElement.FindElement(By.Id("identity-authenticated")).Text); Browser.Equal(string.Empty, () => appElement.FindElement(By.Id("identity-name")).Text); Browser.Equal("(none)", () => appElement.FindElement(By.Id("test-claim")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -56,6 +57,7 @@ public void CascadingAuthenticationState_Authenticated() Browser.Equal("True", () => appElement.FindElement(By.Id("identity-authenticated")).Text); Browser.Equal("someone cool", () => appElement.FindElement(By.Id("identity-name")).Text); Browser.Equal("Test claim value", () => appElement.FindElement(By.Id("test-claim")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -66,6 +68,7 @@ public void AuthorizeViewCases_NoAuthorizationRule_NotAuthorized() WaitUntilExists(By.CssSelector("#no-authorization-rule .not-authorized")); Browser.Equal("You're not authorized, anonymous", () => appElement.FindElement(By.CssSelector("#no-authorization-rule .not-authorized")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -75,6 +78,7 @@ public void AuthorizeViewCases_NoAuthorizationRule_Authorized() var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); Browser.Equal("Welcome, Some User!", () => appElement.FindElement(By.CssSelector("#no-authorization-rule .authorized")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -84,6 +88,7 @@ public void AuthorizeViewCases_RequireRole_Authorized() var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); Browser.Equal("Welcome, Some User!", () => appElement.FindElement(By.CssSelector("#authorize-role .authorized")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -93,6 +98,7 @@ public void AuthorizeViewCases_RequireRole_NotAuthorized() var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); Browser.Equal("You're not authorized, Some User", () => appElement.FindElement(By.CssSelector("#authorize-role .not-authorized")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -102,6 +108,7 @@ public void AuthorizeViewCases_RequirePolicy_Authorized() var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); Browser.Equal("Welcome, Bert!", () => appElement.FindElement(By.CssSelector("#authorize-policy .authorized")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -111,6 +118,7 @@ public void AuthorizeViewCases_RequirePolicy_NotAuthorized() var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases); Browser.Equal("You're not authorized, Mallory", () => appElement.FindElement(By.CssSelector("#authorize-policy .not-authorized")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -120,6 +128,7 @@ public void Router_AllowAnonymous_Anonymous() var appElement = MountAndNavigateToAuthTest(PageAllowingAnonymous); Browser.Equal("Welcome to PageAllowingAnonymous!", () => appElement.FindElement(By.CssSelector("#auth-success")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -129,6 +138,7 @@ public void Router_AllowAnonymous_Authenticated() var appElement = MountAndNavigateToAuthTest(PageAllowingAnonymous); Browser.Equal("Welcome to PageAllowingAnonymous!", () => appElement.FindElement(By.CssSelector("#auth-success")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -138,6 +148,7 @@ public void Router_RequireAuthorization_Authorized() var appElement = MountAndNavigateToAuthTest(PageRequiringAuthorization); Browser.Equal("Welcome to PageRequiringAuthorization!", () => appElement.FindElement(By.CssSelector("#auth-success")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -147,6 +158,7 @@ public void Router_RequireAuthorization_NotAuthorized() var appElement = MountAndNavigateToAuthTest(PageRequiringAuthorization); Browser.Equal("Sorry, anonymous, you're not authorized.", () => appElement.FindElement(By.CssSelector("#auth-failure")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -156,6 +168,7 @@ public void Router_RequirePolicy_Authorized() var appElement = MountAndNavigateToAuthTest(PageRequiringPolicy); Browser.Equal("Welcome to PageRequiringPolicy!", () => appElement.FindElement(By.CssSelector("#auth-success")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -165,6 +178,7 @@ public void Router_RequirePolicy_NotAuthorized() var appElement = MountAndNavigateToAuthTest(PageRequiringPolicy); Browser.Equal("Sorry, Mallory, you're not authorized.", () => appElement.FindElement(By.CssSelector("#auth-failure")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -174,6 +188,7 @@ public void Router_RequireRole_Authorized() var appElement = MountAndNavigateToAuthTest(PageRequiringRole); Browser.Equal("Welcome to PageRequiringRole!", () => appElement.FindElement(By.CssSelector("#auth-success")).Text); + AssertExpectedLayoutUsed(); } [Fact] @@ -183,6 +198,12 @@ public void Router_RequireRole_NotAuthorized() var appElement = MountAndNavigateToAuthTest(PageRequiringRole); Browser.Equal("Sorry, Bert, you're not authorized.", () => appElement.FindElement(By.CssSelector("#auth-failure")).Text); + AssertExpectedLayoutUsed(); + } + + private void AssertExpectedLayoutUsed() + { + WaitUntilExists(By.Id("auth-links")); } protected IWebElement MountAndNavigateToAuthTest(string authLinkText) diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor index 82e6469938bb..edd960202170 100644 --- a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor +++ b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor @@ -8,21 +8,21 @@ and @page authorization rules. *@ - - - @* This will be put back later when implemented - Authorizing... - -
- Sorry, @(context.User.Identity.Name ?? "anonymous"), you're not authorized. -
-
- *@ -
-
- -
- + + + + Authorizing... + +
+ Sorry, @(context.User.Identity.Name ?? "anonymous"), you're not authorized. +
+
+
+
+ +

There's nothing here

+
+
@code { protected override void OnInitialized() diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/Links.razor b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouterLayout.razor similarity index 93% rename from src/Components/test/testassets/BasicTestApp/AuthTest/Links.razor rename to src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouterLayout.razor index 35ff333ed7c0..71c040058fa2 100644 --- a/src/Components/test/testassets/BasicTestApp/AuthTest/Links.razor +++ b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouterLayout.razor @@ -1,3 +1,8 @@ +@inherits LayoutComponentBase + +@Body + +