diff --git a/src/Components/Components/src/DynamicComponent.cs b/src/Components/Components/src/DynamicComponent.cs index 3e5e932a8bef..387d98f8fc0b 100644 --- a/src/Components/Components/src/DynamicComponent.cs +++ b/src/Components/Components/src/DynamicComponent.cs @@ -32,6 +32,7 @@ public DynamicComponent() /// [Parameter] [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] + [EditorRequired] public Type Type { get; set; } = default!; /// diff --git a/src/Components/Components/src/EditorRequiredAttribute.cs b/src/Components/Components/src/EditorRequiredAttribute.cs new file mode 100644 index 000000000000..38a23937668b --- /dev/null +++ b/src/Components/Components/src/EditorRequiredAttribute.cs @@ -0,0 +1,19 @@ +// 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; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// Specifies that the component parameter is required to be provided by the user when authoring it in the editor. + /// + /// If a value for this parameter is not provided, editors or build tools may provide warnings indicating the user to + /// specify a value. This attribute is only valid on properties marked with . + /// + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] + public sealed class EditorRequiredAttribute : Attribute + { + } +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 4058101d38ad..50c92b7e30c5 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -9,6 +9,8 @@ Microsoft.AspNetCore.Components.ComponentApplicationState.PersistAsJson( Microsoft.AspNetCore.Components.ComponentApplicationState.PersistState(string! key, byte[]! value) -> void Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakeAsJson(string! key, out TValue? instance) -> bool Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakePersistedState(string! key, out byte[]? value) -> bool +Microsoft.AspNetCore.Components.EditorRequiredAttribute +Microsoft.AspNetCore.Components.EditorRequiredAttribute.EditorRequiredAttribute() -> void Microsoft.AspNetCore.Components.ErrorBoundaryBase Microsoft.AspNetCore.Components.ErrorBoundaryBase.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? Microsoft.AspNetCore.Components.ErrorBoundaryBase.ChildContent.set -> void diff --git a/src/Components/Components/src/RouteView.cs b/src/Components/Components/src/RouteView.cs index d94ca1083384..76f9f47d311e 100644 --- a/src/Components/Components/src/RouteView.cs +++ b/src/Components/Components/src/RouteView.cs @@ -25,6 +25,7 @@ public class RouteView : IComponent /// displayed and the parameter values that will be supplied to the page. /// [Parameter] + [EditorRequired] public RouteData RouteData { get; set; } /// diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index bc8bd0b3d6c0..3a05e7589bea 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -47,7 +47,9 @@ static readonly IReadOnlyDictionary _emptyParametersDictionary /// /// Gets or sets the assembly that should be searched for components matching the URI. /// - [Parameter] public Assembly AppAssembly { get; set; } + [Parameter] + [EditorRequired] + public Assembly AppAssembly { get; set; } /// /// Gets or sets a collection of additional assemblies that should be searched for components @@ -58,12 +60,16 @@ static readonly IReadOnlyDictionary _emptyParametersDictionary /// /// Gets or sets the content to display when no match is found for the requested route. /// - [Parameter] public RenderFragment NotFound { get; set; } + [Parameter] + [EditorRequired] + public RenderFragment NotFound { get; set; } /// /// Gets or sets the content to display when a match is found for the requested route. /// - [Parameter] public RenderFragment Found { get; set; } + [Parameter] + [EditorRequired] + public RenderFragment Found { get; set; } /// /// Get or sets the content to display when asynchronous navigation is in progress. diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/BoundAttributeDescriptor.cs b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/BoundAttributeDescriptor.cs index ecfd96105f54..6a11b3aba45e 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/BoundAttributeDescriptor.cs +++ b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/BoundAttributeDescriptor.cs @@ -29,6 +29,8 @@ protected BoundAttributeDescriptor(string kind) public bool IsBooleanProperty { get; protected set; } + internal bool IsEditorRequired { get; set; } + public string Name { get; protected set; } public string IndexerNamePrefix { get; protected set; } @@ -81,4 +83,4 @@ public override int GetHashCode() return BoundAttributeDescriptorComparer.Default.GetHashCode(this); } } -} \ No newline at end of file +} diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/BoundAttributeDescriptorBuilder.cs b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/BoundAttributeDescriptorBuilder.cs index b6fb01d7ad01..051f2655d67b 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/BoundAttributeDescriptorBuilder.cs +++ b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/BoundAttributeDescriptorBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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; @@ -28,6 +28,8 @@ public abstract class BoundAttributeDescriptorBuilder public abstract RazorDiagnosticCollection Diagnostics { get; } + internal bool IsEditorRequired { get; set; } + public virtual IReadOnlyList BoundAttributeParameters { get; } public virtual void BindAttributeParameter(Action configure) diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentLoweringPass.cs b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentLoweringPass.cs index 3a7e3c748e28..fa0c73783502 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentLoweringPass.cs +++ b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentLoweringPass.cs @@ -67,7 +67,7 @@ protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentInte } } - private ComponentIntermediateNode RewriteAsComponent(TagHelperIntermediateNode node, TagHelperDescriptor tagHelper) + private static ComponentIntermediateNode RewriteAsComponent(TagHelperIntermediateNode node, TagHelperDescriptor tagHelper) { var component = new ComponentIntermediateNode() { @@ -89,13 +89,54 @@ private ComponentIntermediateNode RewriteAsComponent(TagHelperIntermediateNode n // because we see the nodes in the wrong order. foreach (var childContent in component.ChildContents) { - childContent.ParameterName = childContent.ParameterName ?? component.ChildContentParameterName ?? ComponentMetadata.ChildContent.DefaultParameterName; + childContent.ParameterName ??= component.ChildContentParameterName ?? ComponentMetadata.ChildContent.DefaultParameterName; } + ValidateRequiredAttributes(node, tagHelper, component); + return component; } - private MarkupElementIntermediateNode RewriteAsElement(TagHelperIntermediateNode node) + private static void ValidateRequiredAttributes(TagHelperIntermediateNode node, TagHelperDescriptor tagHelper, ComponentIntermediateNode intermediateNode) + { + if (intermediateNode.Children.Any(c => c is TagHelperDirectiveAttributeIntermediateNode node && (node.TagHelper?.IsSplatTagHelper() ?? false))) + { + // If there are any splat attributes, assume the user may have provided all values. + // This pass runs earlier than ComponentSplatLoweringPass, so we cannot rely on the presence of SplatIntermediateNode to make this check. + return; + } + + foreach (var requiredAttribute in tagHelper.EditorRequiredAttributes) + { + if (!IsPresentAsAttribute(requiredAttribute.Name, intermediateNode)) + { + intermediateNode.Diagnostics.Add( + RazorDiagnosticFactory.CreateComponent_EditorRequiredParameterNotSpecified( + node.Source ?? SourceSpan.Undefined, + intermediateNode.TagName, + requiredAttribute.Name)); + } + } + + static bool IsPresentAsAttribute(string attributeName, ComponentIntermediateNode intermediateNode) + { + foreach (var child in intermediateNode.Children) + { + if (child is ComponentAttributeIntermediateNode attributeNode && attributeName == attributeNode.AttributeName) + { + return true; + } + else if (child is ComponentChildContentIntermediateNode childContent && attributeName == childContent.AttributeName) + { + return true; + } + } + + return false; + } + } + + private static MarkupElementIntermediateNode RewriteAsElement(TagHelperIntermediateNode node) { var result = new MarkupElementIntermediateNode() { diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/DefaultBoundAttributeDescriptorBuilder.cs b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/DefaultBoundAttributeDescriptorBuilder.cs index 52f152b33f8d..446bd56b7460 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/DefaultBoundAttributeDescriptorBuilder.cs +++ b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/DefaultBoundAttributeDescriptorBuilder.cs @@ -125,7 +125,10 @@ public BoundAttributeDescriptor Build() CaseSensitive, parameters, new Dictionary(Metadata), - diagnostics.ToArray()); + diagnostics.ToArray()) + { + IsEditorRequired = IsEditorRequired, + }; return descriptor; } diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/RazorDiagnosticFactory.cs b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/RazorDiagnosticFactory.cs index d88879c15960..2e3c31b4eadf 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/RazorDiagnosticFactory.cs +++ b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/RazorDiagnosticFactory.cs @@ -2,6 +2,7 @@ // 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 Microsoft.AspNetCore.Razor.Language.Legacy; namespace Microsoft.AspNetCore.Razor.Language @@ -588,6 +589,17 @@ public static RazorDiagnostic CreateTagHelper_InconsistentTagStructure(SourceSpa return RazorDiagnostic.Create(TagHelper_InconsistentTagStructure, location, firstDescriptor, secondDescriptor, tagName, nameof(TagMatchingRuleDescriptor.TagStructure)); } + internal static readonly RazorDiagnosticDescriptor Component_EditorRequiredParameterNotSpecified = + new RazorDiagnosticDescriptor( + $"{DiagnosticPrefix}2012", + () => Resources.Component_EditorRequiredParameterNotSpecified, + RazorDiagnosticSeverity.Warning); + + public static RazorDiagnostic CreateComponent_EditorRequiredParameterNotSpecified(SourceSpan location, string tagName, string parameterName) + { + return RazorDiagnostic.Create(Component_EditorRequiredParameterNotSpecified, location, tagName, parameterName); + } + #endregion #region TagHelper Errors diff --git a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/Resources.resx b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/Resources.resx index 8fa60e30c6e6..6d541a509f83 100644 --- a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/Resources.resx +++ b/src/Razor/Microsoft.AspNetCore.Razor.Language/src/Resources.resx @@ -1,4 +1,4 @@ - +