Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d0ea8bf
Add LayoutView component
SteveSandersonMS Jul 30, 2019
edba2f8
Add RouteView component
SteveSandersonMS Jul 30, 2019
29687d8
Update Router to new factoring
SteveSandersonMS Jul 30, 2019
5bc856d
Mark route parameter dictionaries as readonly
SteveSandersonMS Jul 30, 2019
f686840
Update ComponentsApp to use new APIs
SteveSandersonMS Jul 30, 2019
4c896a5
Update client-side StandaloneApp to use new APIs
SteveSandersonMS Jul 30, 2019
5a6a896
Renames for clarity
SteveSandersonMS Jul 30, 2019
d095e99
Convert router E2E test to use new APIs
SteveSandersonMS Jul 31, 2019
59de8be
Simplistic implementation of AuthorizeViewCore which I don't like. Up…
SteveSandersonMS Jul 31, 2019
ac012eb
Improve AuthorizeRouteView by subclassing RouteView
SteveSandersonMS Jul 31, 2019
2310a25
Inline AuthorizeRouteViewCore
SteveSandersonMS Jul 31, 2019
aa29a6f
Cache some delegates
SteveSandersonMS Jul 31, 2019
571f963
Merge the behavior of CascadingAuthenticationState into AuthorizeRout…
SteveSandersonMS Jul 31, 2019
cddcc71
Revert "Merge the behavior of CascadingAuthenticationState into Autho…
SteveSandersonMS Jul 31, 2019
9539832
Some clarifications/simplifications
SteveSandersonMS Jul 31, 2019
dfb75ce
Begin unit tests for AuthorizeRouteView
SteveSandersonMS Jul 31, 2019
4a04b3c
Finish unit tests and implementation for AuthorizeRouteView
SteveSandersonMS Aug 1, 2019
eb9c4e5
Tidy
SteveSandersonMS Aug 1, 2019
4b41722
Typo
SteveSandersonMS Aug 1, 2019
40acef8
Stop using DefaultLayout in router E2E tests, as it interferes with p…
SteveSandersonMS Aug 1, 2019
251bd42
Delete PageDisplay component
SteveSandersonMS Aug 1, 2019
cfcb67d
Move context declarations for clarity
SteveSandersonMS Aug 1, 2019
de640ae
Fix XML doc
SteveSandersonMS Aug 1, 2019
ac349ba
Update E2EPerf app
SteveSandersonMS Aug 1, 2019
184e67f
Update project templates
SteveSandersonMS Aug 1, 2019
0f8a1df
Update src/Components/Components/src/LayoutView.cs
SteveSandersonMS Aug 1, 2019
f9a893f
CR: Check the SetParametersAsync tasks complete successfully
SteveSandersonMS Aug 2, 2019
0454c6a
CR: Rename ComponentRouteData->RouteData
SteveSandersonMS Aug 2, 2019
dca1b78
CR: Rename PageComponentType->PageType
SteveSandersonMS Aug 2, 2019
0236e1e
CR: Rename PageParameters->RouteValues
SteveSandersonMS Aug 2, 2019
262f535
CR: Quotes
SteveSandersonMS Aug 2, 2019
cfcf62c
CR: Tweak delegate caching
SteveSandersonMS Aug 2, 2019
4943442
CR: IsAssignableFrom
SteveSandersonMS Aug 2, 2019
5ba0521
Update ref source
SteveSandersonMS Aug 5, 2019
150376b
Update MVC functional test
SteveSandersonMS Aug 5, 2019
b805da3
Update template test baselines
SteveSandersonMS Aug 5, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<Router AppAssembly="typeof(Program).Assembly">
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<p>Sorry, there's nothing at this address.</p>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The no-auth case for this seems really simple and cool.

Something we could do (that would trade one kind of complexity for another) would be to add a <DefaultLayout> as a cascading value provider component.

I know you tend to prefer using Context="..." but I tend to not. Ultimately we could end up with something like:

<DefaultLayout Layout="typeof(MainLayout)">
<Router AppAssembly="@typeof(Program).Assembly">
    <Found>
        <RouteView RouteData="@context">
    </Found>
    <NotFound>
        <LayoutView>
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>
</DefaultLayout>

In these case there's very little duplication and noise. These are nitpicks though, I'm happy with the result.

I'm not sure it's the right thing to use a cascasing value just to DRY-up the code, but it can make it less verbose.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I was definitely aware of this option but sided against it. I'm concerned with trying to make <LayoutView> generally useful in more scenarios than only the top-level App.razor file. It would be totally reasonable to use <LayoutView> at multiple levels, especially if we have nested routers in the future.

In cases where <LayoutView> is used outside App.razor, you really want to make it use the right layout for its own level, and not randomly inherit a choice from further up the tree (from some other file you can't necessarily find because there's no F12 way of getting to it). It seems greatly preferable for it to be a required explicit parameter.

Of course if someone wants, they can make their own <CascadedLayoutView> that consumed a cascading parameter and outputs a <LayoutView>. The feature is achievable in user code, but I don't fancy it as the built-in default, even if we need slightly more explicitness in App.razor.

</NotFound>
</Router>

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
<Router AppAssembly=typeof(Program).Assembly />
<Router AppAssembly=typeof(Program).Assembly>
<Found Context="routeData">
<RouteView RouteData="@routeData" />
</Found>
<NotFound>
Sorry, there's nothing here.
</NotFound>
</Router>
16 changes: 11 additions & 5 deletions src/Components/Blazor/testassets/StandaloneApp/App.razor
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<!--
Configuring this stuff here is temporary. Later we'll move the app config
into Program.cs, and it won't be necessary to specify AppAssembly.
-->
<Router AppAssembly=typeof(StandaloneApp.Program).Assembly />
<Router AppAssembly=typeof(StandaloneApp.Program).Assembly>
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<h2>Not found</h2>
Sorry, there's nothing at this address.
</LayoutView>
</NotFound>
</Router>

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ public event Microsoft.AspNetCore.Components.AuthenticationStateChangedHandler A
public abstract System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.AuthenticationState> GetAuthenticationStateAsync();
protected void NotifyAuthenticationStateChanged(System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.AuthenticationState> task) { }
}
public sealed partial class AuthorizeRouteView : Microsoft.AspNetCore.Components.RouteView
{
public AuthorizeRouteView() { }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment Authorizing { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.AuthenticationState> NotAuthorized { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
protected override void Render(Microsoft.AspNetCore.Components.RenderTree.RenderTreeBuilder builder) { }
}
public partial class AuthorizeView : Microsoft.AspNetCore.Components.AuthorizeViewCore
{
public AuthorizeView() { }
Expand Down Expand Up @@ -278,6 +287,16 @@ protected LayoutComponentBase() { }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment Body { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
}
public partial class LayoutView : Microsoft.AspNetCore.Components.IComponent
{
public LayoutView() { }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public System.Type Layout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public void Attach(Microsoft.AspNetCore.Components.RenderHandle renderHandle) { }
public System.Threading.Tasks.Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) { throw null; }
}
public sealed partial class LocationChangeException : System.Exception
{
public LocationChangeException(string message, System.Exception innerException) { }
Expand Down Expand Up @@ -323,20 +342,6 @@ public abstract partial class OwningComponentBase<TService> : Microsoft.AspNetCo
protected OwningComponentBase() { }
protected TService Service { get { throw null; } }
}
public partial class PageDisplay : Microsoft.AspNetCore.Components.IComponent
{
public PageDisplay() { }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment Authorizing { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.AuthenticationState> NotAuthorized { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public System.Type Page { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public System.Collections.Generic.IDictionary<string, object> PageParameters { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public void Attach(Microsoft.AspNetCore.Components.RenderHandle renderHandle) { }
public System.Threading.Tasks.Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) { throw null; }
}
[System.AttributeUsageAttribute(System.AttributeTargets.Property, AllowMultiple=false, Inherited=true)]
public sealed partial class ParameterAttribute : System.Attribute
{
Expand Down Expand Up @@ -391,6 +396,23 @@ public sealed partial class RouteAttribute : System.Attribute
public RouteAttribute(string template) { }
public string Template { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
}
public sealed partial class RouteData
{
public RouteData(System.Type pageType, System.Collections.Generic.IReadOnlyDictionary<string, object> routeValues) { }
public System.Type PageType { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
public System.Collections.Generic.IReadOnlyDictionary<string, object> RouteValues { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
}
public partial class RouteView : Microsoft.AspNetCore.Components.IComponent
{
public RouteView() { }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public System.Type DefaultLayout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RouteData RouteData { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public void Attach(Microsoft.AspNetCore.Components.RenderHandle renderHandle) { }
protected virtual void Render(Microsoft.AspNetCore.Components.RenderTree.RenderTreeBuilder builder) { }
public System.Threading.Tasks.Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) { throw null; }
}
}
namespace Microsoft.AspNetCore.Components.CompilerServices
{
Expand Down Expand Up @@ -648,15 +670,12 @@ public Router() { }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public System.Reflection.Assembly AppAssembly { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment Authorizing { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.AuthenticationState> NotAuthorized { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.RouteData> Found { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment NotFound { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public void Attach(Microsoft.AspNetCore.Components.RenderHandle renderHandle) { }
public void Dispose() { }
System.Threading.Tasks.Task Microsoft.AspNetCore.Components.IHandleAfterRender.OnAfterRenderAsync() { throw null; }
protected virtual void Render(Microsoft.AspNetCore.Components.RenderTree.RenderTreeBuilder builder, System.Type handler, System.Collections.Generic.IDictionary<string, object> parameters) { }
public System.Threading.Tasks.Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) { throw null; }
}
}
118 changes: 118 additions & 0 deletions src/Components/Components/src/Auth/AuthorizeRouteView.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Auth;
using Microsoft.AspNetCore.Components.RenderTree;

namespace Microsoft.AspNetCore.Components
{
/// <summary>
/// Combines the behaviors of <see cref="AuthorizeView"/> and <see cref="RouteView"/>,
/// so that it displays the page matching the specified route but only if the user
/// is authorized to see it.
///
/// Additionally, this component supplies a cascading parameter of type <see cref="Task{AuthenticationState}"/>,
/// which makes the user's current authentication state available to descendants.
/// </summary>
public sealed class AuthorizeRouteView : RouteView
{
// We expect applications to supply their own authorizing/not-authorized content, but
// it's better to have defaults than to make the parameters mandatory because in some
// cases they will never be used (e.g., "authorizing" in out-of-box server-side Blazor)
private static readonly RenderFragment<AuthenticationState> _defaultNotAuthorizedContent
= state => builder => builder.AddContent(0, "Not authorized");
private static readonly RenderFragment _defaultAuthorizingContent
= builder => builder.AddContent(0, "Authorizing...");

private readonly RenderFragment _renderAuthorizeRouteViewCoreDelegate;
private readonly RenderFragment<AuthenticationState> _renderAuthorizedDelegate;
private readonly RenderFragment<AuthenticationState> _renderNotAuthorizedDelegate;
private readonly RenderFragment _renderAuthorizingDelegate;

public AuthorizeRouteView()
{
// Cache the rendering delegates so that we only construct new closure instances
// when they are actually used (e.g., we never prepare a RenderFragment bound to
// the NotAuthorized content except when you are displaying that particular state)
RenderFragment renderBaseRouteViewDelegate = builder => base.Render(builder);
_renderAuthorizedDelegate = authenticateState => renderBaseRouteViewDelegate;
_renderNotAuthorizedDelegate = authenticationState => builder => RenderNotAuthorizedInDefaultLayout(builder, authenticationState);
_renderAuthorizingDelegate = RenderAuthorizingInDefaultLayout;
_renderAuthorizeRouteViewCoreDelegate = RenderAuthorizeRouteViewCore;
}

/// <summary>
/// The content that will be displayed if the user is not authorized.
/// </summary>
[Parameter]
public RenderFragment<AuthenticationState> NotAuthorized { get; set; }

/// <summary>
/// The content that will be displayed while asynchronous authorization is in progress.
/// </summary>
[Parameter]
public RenderFragment Authorizing { get; set; }

[CascadingParameter]
private Task<AuthenticationState> ExistingCascadedAuthenticationState { get; set; }

/// <inheritdoc />
protected override void Render(RenderTreeBuilder builder)
{
if (ExistingCascadedAuthenticationState != null)
{
// If this component is already wrapped in a <CascadingAuthenticationState> (or another
// compatible provider), then don't interfere with the cascaded authentication state.
_renderAuthorizeRouteViewCoreDelegate(builder);
}
else
{
// Otherwise, implicitly wrap the output in a <CascadingAuthenticationState>
builder.OpenComponent<CascadingAuthenticationState>(0);
builder.AddAttribute(1, nameof(CascadingAuthenticationState.ChildContent), _renderAuthorizeRouteViewCoreDelegate);
builder.CloseComponent();
}
}

private void RenderAuthorizeRouteViewCore(RenderTreeBuilder builder)
{
builder.OpenComponent<AuthorizeRouteViewCore>(0);
builder.AddAttribute(1, nameof(AuthorizeRouteViewCore.RouteData), RouteData);
builder.AddAttribute(2, nameof(AuthorizeRouteViewCore.Authorized), _renderAuthorizedDelegate);
builder.AddAttribute(3, nameof(AuthorizeRouteViewCore.Authorizing), _renderAuthorizingDelegate);
builder.AddAttribute(4, nameof(AuthorizeRouteViewCore.NotAuthorized), _renderNotAuthorizedDelegate);
builder.CloseComponent();
}

private void RenderContentInDefaultLayout(RenderTreeBuilder builder, RenderFragment content)
{
builder.OpenComponent<LayoutView>(0);
builder.AddAttribute(1, nameof(LayoutView.Layout), DefaultLayout);
builder.AddAttribute(2, nameof(LayoutView.ChildContent), content);
builder.CloseComponent();
}

private void RenderNotAuthorizedInDefaultLayout(RenderTreeBuilder builder, AuthenticationState authenticationState)
{
var content = NotAuthorized ?? _defaultNotAuthorizedContent;
RenderContentInDefaultLayout(builder, content(authenticationState));
}

private void RenderAuthorizingInDefaultLayout(RenderTreeBuilder builder)
{
var content = Authorizing ?? _defaultAuthorizingContent;
RenderContentInDefaultLayout(builder, content);
}

private class AuthorizeRouteViewCore : AuthorizeViewCore
{
[Parameter]
public RouteData RouteData { get; set; }

protected override IAuthorizeData[] GetAuthorizeData()
=> AttributeAuthorizeDataCache.GetAuthorizeDataForType(RouteData.PageType);
}
}
}
12 changes: 10 additions & 2 deletions src/Components/Components/src/Auth/AuthorizeViewCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,20 @@ public abstract class AuthorizeViewCore : ComponentBase
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
// We're using the same sequence number for each of the content items here
// so that we can update existing instances if they are the same shape
if (currentAuthenticationState == null)
{
builder.AddContent(0, Authorizing);
}
else if (isAuthorized)
{
var authorized = Authorized ?? ChildContent;
builder.AddContent(1, authorized?.Invoke(currentAuthenticationState));
builder.AddContent(0, authorized?.Invoke(currentAuthenticationState));
}
else
{
builder.AddContent(2, NotAuthorized?.Invoke(currentAuthenticationState));
builder.AddContent(0, NotAuthorized?.Invoke(currentAuthenticationState));
}
}

Expand Down Expand Up @@ -102,6 +104,12 @@ protected override async Task OnParametersSetAsync()
private async Task<bool> 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(
Expand Down
77 changes: 77 additions & 0 deletions src/Components/Components/src/LayoutView.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Displays the specified content inside the specified layout and any further
/// nested layouts.
/// </summary>
public class LayoutView : IComponent
{
private static readonly RenderFragment EmptyRenderFragment = builder => { };

private RenderHandle _renderHandle;

/// <summary>
/// Gets or sets the content to display.
/// </summary>
[Parameter]
public RenderFragment ChildContent { get; set; }

/// <summary>
/// Gets or sets the type of the layout in which to display the content.
/// The type must implement <see cref="IComponent"/> and accept a parameter named <see cref="LayoutComponentBase.Body"/>.
/// </summary>
[Parameter]
public Type Layout { get; set; }

/// <inheritdoc />
public void Attach(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}

/// <inheritdoc />
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<LayoutAttribute>()?.LayoutType;
}
}
Loading