Fix for #1052 - ViewComponents should support fully qualified names

This change adds the concept of a full-name to viewcomponents. View
components can be invoked using either the short name or long name. If the
provided string contains a '.' character, then it will be compared against
full names, otherwise it will be matched against short names only.

The short name is used for view lookups.

If the name is explicitly set via ViewComponent attribute, then the full
name is the name provided. The short name is the portion of the name after
the last '.'. If there are no dots, then the short name and full name are
the same.

If the name is not set explicitly, then it is inferred from the Type and
namespace name. The short name is the Type name, minus the 'ViewComponent'
suffix (if present). The full name is the namespace of the defining class,
plus the short name.
This commit is contained in:
Ryan Nowak 2014-10-23 14:33:54 -07:00
parent dc1aaf0664
commit 83187945d1
11 changed files with 404 additions and 16 deletions

View File

@ -251,7 +251,7 @@ namespace Microsoft.AspNet.Mvc.Core
}
/// <summary>
/// The view component name '{0}' matched multiple types: {1}
/// The view component name '{0}' matched multiple types:{1}{2}
/// </summary>
internal static string ViewComponent_AmbiguousTypeMatch
{
@ -259,11 +259,11 @@ namespace Microsoft.AspNet.Mvc.Core
}
/// <summary>
/// The view component name '{0}' matched multiple types: {1}
/// The view component name '{0}' matched multiple types:{1}{2}
/// </summary>
internal static string FormatViewComponent_AmbiguousTypeMatch(object p0, object p1)
internal static string FormatViewComponent_AmbiguousTypeMatch(object p0, object p1, object p2)
{
return string.Format(CultureInfo.CurrentCulture, GetString("ViewComponent_AmbiguousTypeMatch"), p0, p1);
return string.Format(CultureInfo.CurrentCulture, GetString("ViewComponent_AmbiguousTypeMatch"), p0, p1, p2);
}
/// <summary>
@ -1530,6 +1530,22 @@ namespace Microsoft.AspNet.Mvc.Core
return string.Format(CultureInfo.CurrentCulture, GetString("FileResult_InvalidPath"), p0);
}
/// <summary>
/// Type: '{0}' - Name: '{1}'
/// </summary>
internal static string ViewComponent_AmbiguousTypeMatch_Item
{
get { return GetString("ViewComponent_AmbiguousTypeMatch_Item"); }
}
/// <summary>
/// Type: '{0}' - Name: '{1}'
/// </summary>
internal static string FormatViewComponent_AmbiguousTypeMatch_Item(object p0, object p1)
{
return string.Format(CultureInfo.CurrentCulture, GetString("ViewComponent_AmbiguousTypeMatch_Item"), p0, p1);
}
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);

View File

@ -163,7 +163,8 @@
<value>The class ReflectedActionFilterEndPoint only supports ReflectedActionDescriptors.</value>
</data>
<data name="ViewComponent_AmbiguousTypeMatch" xml:space="preserve">
<value>The view component name '{0}' matched multiple types: {1}</value>
<value>The view component name '{0}' matched multiple types:{1}{2}</value>
<comment>{1} is the newline character</comment>
</data>
<data name="ViewComponent_AsyncMethod_ShouldReturnTask" xml:space="preserve">
<value>The async view component method '{0}' should be declared to return Task&lt;T&gt;.</value>
@ -413,4 +414,7 @@
<value>Could not find file: {0}</value>
<comment>{0} is the value for the provided path</comment>
</data>
<data name="ViewComponent_AmbiguousTypeMatch_Item" xml:space="preserve">
<value>Type: '{0}' - Name: '{1}'</value>
</data>
</root>

View File

@ -2,7 +2,10 @@
// 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.Diagnostics.Contracts;
using System.Linq;
using System.Reflection;
using Microsoft.AspNet.Mvc.Core;
namespace Microsoft.AspNet.Mvc
@ -21,30 +24,84 @@ namespace Microsoft.AspNet.Mvc
var assemblies = _assemblyProvider.CandidateAssemblies;
var types = assemblies.SelectMany(a => a.DefinedTypes);
var components =
var candidates =
types
.Where(ViewComponentConventions.IsComponent)
.Select(c => new { Name = ViewComponentConventions.GetComponentName(c), Type = c.AsType() });
.Where(t => IsViewComponentType(t))
.Select(CreateCandidate);
var matching =
components
.Where(c => string.Equals(c.Name, componentName, StringComparison.OrdinalIgnoreCase))
.ToArray();
// ViewComponent names can either be fully-qualified, or refer to the 'short-name'. If the provided
// name contains a '.' - then it's a fully-qualified name.
var matching = new List<ViewComponentCandidate>();
if (componentName.Contains("."))
{
matching.AddRange(candidates.Where(
c => string.Equals(c.FullName, componentName, StringComparison.OrdinalIgnoreCase)));
}
else
{
matching.AddRange(candidates.Where(
c => string.Equals(c.ShortName, componentName, StringComparison.OrdinalIgnoreCase)));
}
if (matching.Length == 0)
if (matching.Count == 0)
{
return null;
}
else if (matching.Length == 1)
else if (matching.Count == 1)
{
return matching[0].Type;
}
else
{
var typeNames = string.Join(Environment.NewLine, matching.Select(t => t.Type.FullName));
var matchedTypes = new List<string>();
foreach (var candidate in matching)
{
matchedTypes.Add(Resources.FormatViewComponent_AmbiguousTypeMatch_Item(
candidate.Type.FullName,
candidate.FullName));
}
var typeNames = string.Join(Environment.NewLine, matchedTypes);
throw new InvalidOperationException(
Resources.FormatViewComponent_AmbiguousTypeMatch(componentName, typeNames));
Resources.FormatViewComponent_AmbiguousTypeMatch(componentName, Environment.NewLine, typeNames));
}
}
protected virtual bool IsViewComponentType([NotNull] TypeInfo typeInfo)
{
return ViewComponentConventions.IsComponent(typeInfo);
}
private static ViewComponentCandidate CreateCandidate(TypeInfo typeInfo)
{
var candidate = new ViewComponentCandidate()
{
FullName = ViewComponentConventions.GetComponentFullName(typeInfo),
ShortName = ViewComponentConventions.GetComponentName(typeInfo),
Type = typeInfo.AsType(),
};
Contract.Assert(!string.IsNullOrEmpty(candidate.FullName));
var separatorIndex = candidate.FullName.LastIndexOf(".");
if (separatorIndex >= 0)
{
candidate.ShortName = candidate.FullName.Substring(separatorIndex + 1);
}
else
{
candidate.ShortName = candidate.FullName;
}
return candidate;
}
private class ViewComponentCandidate
{
public string FullName { get; set; }
public string ShortName { get; set; }
public Type Type { get; set; }
}
}
}

View File

@ -14,10 +14,44 @@ namespace Microsoft.AspNet.Mvc
{
var attribute = componentType.GetCustomAttribute<ViewComponentAttribute>();
if (attribute != null && !string.IsNullOrEmpty(attribute.Name))
{
var separatorIndex = attribute.Name.LastIndexOf('.');
if (separatorIndex >= 0)
{
return attribute.Name.Substring(separatorIndex + 1);
}
else
{
return attribute.Name;
}
}
return GetShortNameByConvention(componentType);
}
public static string GetComponentFullName([NotNull] TypeInfo componentType)
{
var attribute = componentType.GetCustomAttribute<ViewComponentAttribute>();
if (!string.IsNullOrEmpty(attribute?.Name))
{
return attribute.Name;
}
// If the view component didn't define a name explicitly then use the namespace + the
// 'short name'.
var shortName = GetShortNameByConvention(componentType);
if (string.IsNullOrEmpty(componentType.Namespace))
{
return shortName;
}
else
{
return componentType.Namespace + "." + shortName;
}
}
private static string GetShortNameByConvention(TypeInfo componentType)
{
if (componentType.Name.EndsWith(ViewComponentSuffix, StringComparison.OrdinalIgnoreCase))
{
return componentType.Name.Substring(0, componentType.Name.Length - ViewComponentSuffix.Length);

View File

@ -0,0 +1,193 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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.Linq;
using System.Reflection;
using Xunit;
namespace Microsoft.AspNet.Mvc
{
public class DefaultViewComponentSelectorTest
{
[Fact]
public void SelectComponent_ByShortNameWithSuffix()
{
// Arrange
var selector = CreateSelector();
// Act
var result = selector.SelectComponent("Suffix");
// Assert
Assert.Equal(typeof(SuffixViewComponent), result);
}
[Fact]
public void SelectComponent_ByLongNameWithSuffix()
{
// Arrange
var selector = CreateSelector();
// Act
var result = selector.SelectComponent("Microsoft.AspNet.Mvc.Suffix");
// Assert
Assert.Equal(typeof(SuffixViewComponent), result);
}
[Fact]
public void SelectComponent_ByShortNameWithoutSuffix()
{
// Arrange
var selector = CreateSelector();
// Act
var result = selector.SelectComponent("WithoutSuffix");
// Assert
Assert.Equal(typeof(WithoutSuffix), result);
}
[Fact]
public void SelectComponent_ByLongNameWithoutSuffix()
{
// Arrange
var selector = CreateSelector();
// Act
var result = selector.SelectComponent("Microsoft.AspNet.Mvc.WithoutSuffix");
// Assert
Assert.Equal(typeof(WithoutSuffix), result);
}
[Fact]
public void SelectComponent_ByAttribute()
{
// Arrange
var selector = CreateSelector();
// Act
var result = selector.SelectComponent("ByAttribute");
// Assert
Assert.Equal(typeof(ByAttribute), result);
}
[Fact]
public void SelectComponent_ByNamingConvention()
{
// Arrange
var selector = CreateSelector();
// Act
var result = selector.SelectComponent("ByNamingConvention");
// Assert
Assert.Equal(typeof(ByNamingConventionViewComponent), result);
}
[Fact]
public void SelectComponent_Ambiguity()
{
// Arrange
var selector = CreateSelector();
var expected =
"The view component name 'Ambiguous' matched multiple types:" + Environment.NewLine +
"Type: 'Microsoft.AspNet.Mvc.DefaultViewComponentSelectorTest+Ambiguous1' - " +
"Name: 'Namespace1.Ambiguous'" + Environment.NewLine +
"Type: 'Microsoft.AspNet.Mvc.DefaultViewComponentSelectorTest+Ambiguous2' - " +
"Name: 'Namespace2.Ambiguous'";
// Act
var ex = Assert.Throws<InvalidOperationException>(() => selector.SelectComponent("Ambiguous"));
// Assert
Assert.Equal(expected, ex.Message);
}
[Fact]
public void SelectComponent_FullNameToAvoidAmbiguity()
{
// Arrange
var selector = CreateSelector();
// Act
var result = selector.SelectComponent("Namespace1.Ambiguous");
// Assert
Assert.Equal(typeof(Ambiguous1), result);
}
[Theory]
[InlineData("FullNameInAttribute")]
[InlineData("CoolNameSpace.FullNameInAttribute")]
public void SelectComponent_FullNameInAttribute(string name)
{
// Arrange
var selector = CreateSelector();
// Act
var result = selector.SelectComponent(name);
// Assert
Assert.Equal(typeof(FullNameInAttribute), result);
}
private IViewComponentSelector CreateSelector()
{
return new FilteredViewComponentSelector();
}
private class SuffixViewComponent : ViewComponent
{
}
private class WithoutSuffix : ViewComponent
{
}
private class ByNamingConventionViewComponent
{
}
[ViewComponent]
private class ByAttribute
{
}
[ViewComponent(Name = "Namespace1.Ambiguous")]
private class Ambiguous1
{
}
[ViewComponent(Name = "Namespace2.Ambiguous")]
private class Ambiguous2
{
}
[ViewComponent(Name = "CoolNameSpace.FullNameInAttribute")]
private class FullNameInAttribute
{
}
// This will only consider types nested inside this class as ViewComponent classes
private class FilteredViewComponentSelector : DefaultViewComponentSelector
{
public FilteredViewComponentSelector()
: base(new StaticAssemblyProvider())
{
AllowedTypes = typeof(DefaultViewComponentSelectorTest).GetNestedTypes(BindingFlags.NonPublic);
}
public Type[] AllowedTypes { get; private set; }
protected override bool IsViewComponentType([NotNull] TypeInfo typeInfo)
{
return AllowedTypes.Contains(typeInfo.AsType());
}
}
}
}

View File

@ -64,5 +64,35 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
// Assert
Assert.Equal("10", body.Trim());
}
[Theory]
[InlineData("ViewComponentWebSite.Namespace1.SameName")]
[InlineData("ViewComponentWebSite.Namespace2.SameName")]
public async Task ViewComponents_FullName(string name)
{
var server = TestServer.Create(_provider, _app);
var client = server.CreateClient();
// Act
var body = await client.GetStringAsync("http://localhost/FullName/Invoke?name=" + name);
// Assert
Assert.Equal(name, body.Trim());
}
[Fact]
public async Task ViewComponents_ShortNameUsedForViewLookup()
{
var server = TestServer.Create(_provider, _app);
var client = server.CreateClient();
var name = "ViewComponentWebSite.Integer";
// Act
var body = await client.GetStringAsync("http://localhost/FullName/Invoke?name=" + name);
// Assert
Assert.Equal("17", body.Trim());
}
}
}

View File

@ -0,0 +1,16 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNet.Mvc;
namespace ViewComponentWebSite
{
public class FullNameController : Controller
{
public IActionResult Invoke(string name)
{
ViewBag.Name = name;
return View();
}
}
}

View File

@ -7,6 +7,11 @@ namespace ViewComponentWebSite
{
public class IntegerViewComponent : ViewComponent
{
public IViewComponentResult Invoke()
{
return Invoke(17);
}
public IViewComponentResult Invoke(int valueFromView)
{
return View(valueFromView);

View File

@ -0,0 +1,16 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNet.Mvc;
namespace ViewComponentWebSite.Namespace1
{
// The full name is different here from the other view component with the same short name.
public class SameNameViewComponent : ViewComponent
{
public string Invoke()
{
return "ViewComponentWebSite.Namespace1.SameName";
}
}
}

View File

@ -0,0 +1,16 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNet.Mvc;
namespace ViewComponentWebSite.Namespace2
{
// The full name is different here from the other view component with the same short name.
public class SameNameViewComponent : ViewComponent
{
public string Invoke()
{
return "ViewComponentWebSite.Namespace2.SameName";
}
}
}

View File

@ -0,0 +1 @@
@Component.Invoke(ViewBag.Name)