Add support for areas to Razor Pages

Fixes #6926
This commit is contained in:
Pranav K 2017-12-12 15:01:40 -08:00
parent c8cabde1f1
commit dfa085afaf
43 changed files with 1353 additions and 101 deletions

View File

@ -98,8 +98,8 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
/// The value of <see cref="ActionName"/> is considered an implicit route value corresponding
/// to the key <c>action</c> and the value of <see cref="ControllerModel.ControllerName"/> is
/// considered an implicit route value corresponding to the key <c>controller</c>. These entries
/// will be added to <see cref="ActionDescriptor.RouteValues"/>, but will not be visible in
/// <see cref="RouteValues"/>.
/// will be implicitly added to <see cref="ActionDescriptor.RouteValues"/> when the action
/// descriptor is created, but will not be visible in <see cref="RouteValues"/>.
/// </para>
/// <para>
/// Entries in <see cref="RouteValues"/> can override entries in

View File

@ -333,6 +333,11 @@ namespace Microsoft.AspNetCore.Mvc.Razor
{
return _options.ViewLocationFormats;
}
else if (!string.IsNullOrEmpty(context.AreaName) &&
!string.IsNullOrEmpty(context.PageName))
{
return _options.AreaPageViewLocationFormats;
}
else if (!string.IsNullOrEmpty(context.PageName))
{
return _options.PageViewLocationFormats;

View File

@ -112,12 +112,47 @@ namespace Microsoft.AspNetCore.Mvc.Razor
/// /Pages/Account/Manage/{0}.cshtml
/// /Pages/Account/{0}.cshtml
/// /Pages/{0}.cshtml
/// /Views/Shared/{0}.cshtml
/// /Pages/Shared/{0}.cshtml
/// /Views/Shared/{0}.cshtml
/// </para>
/// </remarks>
public IList<string> PageViewLocationFormats { get; } = new List<string>();
/// <summary>
/// Gets the locations where <see cref="RazorViewEngine"/> will search for views (such as layouts and partials)
/// when searched from the context of rendering a Razor Page within an area.
/// </summary>
/// <remarks>
/// <para>
/// Locations are format strings (see https://msdn.microsoft.com/en-us/library/txafckwd.aspx) which may contain
/// the following format items:
/// </para>
/// <list type="bullet">
/// <item>
/// <description>{0} - View Name</description>
/// </item>
/// <item>
/// <description>{1} - Page Name</description>
/// </item>
/// <item>
/// <description>{2} - Area Name</description>
/// </item>
/// </list>
/// <para>
/// <see cref="AreaPageViewLocationFormats"/> work in tandem with a view location expander to perform hierarchical
/// path lookups. For instance, given a Page like /Areas/Account/Pages/Manage/User.cshtml using /Areas as the area pages root and
/// /Pages as the root, the view engine will search for views in the following locations:
///
/// /Areas/Accounts/Pages/Manage/{0}.cshtml
/// /Areas/Accounts/Pages/{0}.cshtml
/// /Areas/Accounts/Pages/Shared/{0}.cshtml
/// /Areas/Accounts/Views/Shared/{0}.cshtml
/// /Pages/Shared/{0}.cshtml
/// /Views/Shared/{0}.cshtml
/// </para>
/// </remarks>
public IList<string> AreaPageViewLocationFormats { get; } = new List<string>();
/// <summary>
/// Gets the <see cref="MetadataReference" /> instances that should be included in Razor compilation, along with
/// those discovered by <see cref="MetadataReferenceFeatureProvider" />s.

View File

@ -4,6 +4,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Routing;
namespace Microsoft.AspNetCore.Mvc.ApplicationModels
{
@ -19,21 +22,12 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
/// <param name="viewEnginePath">The path relative to the base path for page discovery.</param>
public PageRouteModel(string relativePath, string viewEnginePath)
{
if (relativePath == null)
{
throw new ArgumentNullException(nameof(relativePath));
}
if (viewEnginePath == null)
{
throw new ArgumentNullException(nameof(viewEnginePath));
}
RelativePath = relativePath;
ViewEnginePath = viewEnginePath;
RelativePath = relativePath ?? throw new ArgumentNullException(nameof(relativePath));
ViewEnginePath = viewEnginePath ?? throw new ArgumentNullException(nameof(viewEnginePath));
Properties = new Dictionary<object, object>();
Selectors = new List<SelectorModel>();
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
@ -52,6 +46,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
Properties = new Dictionary<object, object>(other.Properties);
Selectors = new List<SelectorModel>(other.Selectors.Select(m => new SelectorModel(m)));
RouteValues = new Dictionary<string, string>(other.RouteValues, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
@ -62,6 +57,9 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
/// <summary>
/// Gets the path relative to the base path for page discovery.
/// </summary>
/// <remarks>
/// For area pages, this path is calculated relative to the <see cref="RazorPagesOptions.RootDirectory"/> of the specific area.
/// </remarks>
public string ViewEnginePath { get; }
/// <summary>
@ -73,5 +71,18 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
/// Gets the <see cref="SelectorModel"/> instances.
/// </summary>
public IList<SelectorModel> Selectors { get; }
/// <summary>
/// Gets a collection of route values that must be present in the <see cref="RouteData.Values"/>
/// for the corresponding page to be selected.
/// </summary>
/// <remarks>
/// <para>
/// The value of <see cref="ViewEnginePath"/> is considered an implicit route value corresponding
/// to the key <c>page</c>. These entries will be implicitly added to <see cref="ActionDescriptor.RouteValues"/>
/// when the action descriptor is created, but will not be visible in <see cref="RouteValues"/>.
/// </para>
/// </remarks>
public IDictionary<string, string> RouteValues { get; }
}
}
}

View File

@ -73,7 +73,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
foreach (var selector in model.Selectors)
{
actions.Add(new PageActionDescriptor()
var descriptor = new PageActionDescriptor()
{
AttributeRouteInfo = new AttributeRouteInfo()
{
@ -87,12 +87,23 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
FilterDescriptors = Array.Empty<FilterDescriptor>(),
Properties = new Dictionary<object, object>(model.Properties),
RelativePath = model.RelativePath,
RouteValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "page", model.ViewEnginePath },
},
ViewEnginePath = model.ViewEnginePath,
});
};
foreach (var kvp in model.RouteValues)
{
if (!descriptor.RouteValues.ContainsKey(kvp.Key))
{
descriptor.RouteValues.Add(kvp.Key, kvp.Value);
}
}
if (!descriptor.RouteValues.ContainsKey("page"))
{
descriptor.RouteValues.Add("page", model.ViewEnginePath);
}
actions.Add(descriptor);
}
}
}

View File

@ -6,8 +6,10 @@ using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Razor.Compilation;
using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
@ -17,14 +19,17 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
private readonly object _cacheLock = new object();
private readonly ApplicationPartManager _applicationManager;
private readonly RazorPagesOptions _pagesOptions;
private readonly ILogger<CompiledPageRouteModelProvider> _logger;
private List<PageRouteModel> _cachedModels;
public CompiledPageRouteModelProvider(
ApplicationPartManager applicationManager,
IOptions<RazorPagesOptions> pagesOptionsAccessor)
IOptions<RazorPagesOptions> pagesOptionsAccessor,
ILoggerFactory loggerFactory)
{
_applicationManager = applicationManager;
_pagesOptions = pagesOptionsAccessor.Value;
_logger = loggerFactory.CreateLogger<CompiledPageRouteModelProvider>();
}
public int Order => -1000;
@ -58,26 +63,69 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
rootDirectory = rootDirectory + "/";
}
var areaRootDirectory = _pagesOptions.AreaRootDirectory;
if (!areaRootDirectory.EndsWith("/", StringComparison.Ordinal))
{
areaRootDirectory = areaRootDirectory + "/";
}
var cachedApplicationModels = new List<PageRouteModel>();
foreach (var viewDescriptor in GetViewDescriptors(_applicationManager))
{
if (!viewDescriptor.RelativePath.StartsWith(rootDirectory, StringComparison.OrdinalIgnoreCase))
PageRouteModel model = null;
if (viewDescriptor.RelativePath.StartsWith(rootDirectory, StringComparison.OrdinalIgnoreCase))
{
continue;
model = GetPageRouteModel(rootDirectory, viewDescriptor);
}
else if (_pagesOptions.EnableAreas && viewDescriptor.RelativePath.StartsWith(areaRootDirectory, StringComparison.OrdinalIgnoreCase))
{
model = GetAreaPageRouteModel(areaRootDirectory, viewDescriptor);
}
var viewEnginePath = GetViewEnginePath(rootDirectory, viewDescriptor.RelativePath);
var model = new PageRouteModel(viewDescriptor.RelativePath, viewEnginePath);
var pageAttribute = (RazorPageAttribute)viewDescriptor.ViewAttribute;
PageSelectorModel.PopulateDefaults(model, pageAttribute.RouteTemplate);
cachedApplicationModels.Add(model);
if (model != null)
{
cachedApplicationModels.Add(model);
}
}
_cachedModels = cachedApplicationModels;
}
}
private PageRouteModel GetPageRouteModel(string rootDirectory, CompiledViewDescriptor viewDescriptor)
{
var viewEnginePath = GetRootTrimmedPath(rootDirectory, viewDescriptor.RelativePath);
if (viewEnginePath.EndsWith(RazorViewEngine.ViewExtension, StringComparison.OrdinalIgnoreCase))
{
viewEnginePath = viewEnginePath.Substring(0, viewEnginePath.Length - RazorViewEngine.ViewExtension.Length);
}
var model = new PageRouteModel(viewDescriptor.RelativePath, viewEnginePath);
var pageAttribute = (RazorPageAttribute)viewDescriptor.ViewAttribute;
PageSelectorModel.PopulateDefaults(model, viewEnginePath, pageAttribute.RouteTemplate);
return model;
}
private PageRouteModel GetAreaPageRouteModel(string areaRootDirectory, CompiledViewDescriptor viewDescriptor)
{
var rootTrimmedPath = GetRootTrimmedPath(areaRootDirectory, viewDescriptor.RelativePath);
if (PageSelectorModel.TryParseAreaPath(_pagesOptions, rootTrimmedPath, _logger, out var result))
{
var model = new PageRouteModel(viewDescriptor.RelativePath, result.viewEnginePath)
{
RouteValues = { ["area"] = result.areaName },
};
var pageAttribute = (RazorPageAttribute)viewDescriptor.ViewAttribute;
PageSelectorModel.PopulateDefaults(model, result.pageRoute, pageAttribute.RouteTemplate);
return model;
}
// We were unable to parse the path to match the format we expect /Areas/AreaName/Pages/PagePath.cshtml
return null;
}
/// <summary>
/// Gets the sequence of <see cref="CompiledViewDescriptor"/> from <paramref name="applicationManager"/>.
/// </summary>
@ -96,20 +144,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
return viewsFeature.ViewDescriptors.Where(d => d.IsPrecompiled && d.ViewAttribute is RazorPageAttribute);
}
private string GetViewEnginePath(string rootDirectory, string path)
private string GetRootTrimmedPath(string rootDirectory, string path)
{
var endIndex = path.LastIndexOf('.');
if (endIndex == -1)
{
endIndex = path.Length;
}
// rootDirectory = "/Pages/AllMyPages/"
// path = "/Pages/AllMyPages/Home.cshtml"
// Result = "/Home"
// Result = "/Home.cshtml"
var startIndex = rootDirectory.Length - 1;
return path.Substring(startIndex, endIndex - startIndex);
return path.Substring(startIndex);
}
}
}

View File

@ -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 System.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Infrastructure;
@ -16,7 +17,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
public class PageActionDescriptorChangeProvider : IActionDescriptorChangeProvider
{
private readonly IFileProvider _fileProvider;
private readonly string _searchPattern;
private readonly string[] _searchPatterns;
private readonly string[] _additionalFilesToTrack;
public PageActionDescriptorChangeProvider(
@ -45,29 +46,61 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
Debug.Assert(!string.IsNullOrEmpty(rootDirectory));
rootDirectory = rootDirectory.TrimEnd('/');
var importFileAtPagesRoot = rootDirectory + "/" + templateEngine.Options.ImportsFileName;
_additionalFilesToTrack = templateEngine.GetImportItems(importFileAtPagesRoot)
.Select(item => item.FilePath)
.ToArray();
// Search pattern that matches all cshtml files under the Pages RootDirectory
var pagesRootSearchPattern = rootDirectory + "/**/*.cshtml";
_searchPattern = rootDirectory + "/**/*.cshtml";
// pagesRootSearchPattern will miss _ViewImports outside the RootDirectory despite these influencing
// compilation. e.g. when RootDirectory = /Dir1/Dir2, the search pattern will ignore changes to
// [/_ViewImports.cshtml, /Dir1/_ViewImports.cshtml]. We need to additionally account for these.
var importFileAtPagesRoot = rootDirectory + "/" + templateEngine.Options.ImportsFileName;
var additionalImportFilePaths = templateEngine.GetImportItems(importFileAtPagesRoot)
.Select(item => item.FilePath);
if (razorPagesOptions.Value.EnableAreas)
{
var areaRootDirectory = razorPagesOptions.Value.AreaRootDirectory;
Debug.Assert(!string.IsNullOrEmpty(areaRootDirectory));
areaRootDirectory = areaRootDirectory.TrimEnd('/');
// Search pattern that matches all cshtml files under the Pages AreaRootDirectory
var areaRootSearchPattern = areaRootDirectory + "/**/*.cshtml";
var importFileAtAreaPagesRoot = areaRootDirectory + "/" + templateEngine.Options.ImportsFileName;
var importPathsOutsideAreaPagesRoot = templateEngine.GetImportItems(importFileAtAreaPagesRoot)
.Select(item => item.FilePath);
additionalImportFilePaths = additionalImportFilePaths
.Concat(importPathsOutsideAreaPagesRoot)
.Distinct(StringComparer.OrdinalIgnoreCase);
_searchPatterns = new[]
{
pagesRootSearchPattern,
areaRootSearchPattern
};
}
else
{
_searchPatterns = new[] { pagesRootSearchPattern, };
}
_additionalFilesToTrack = additionalImportFilePaths.ToArray();
}
public IChangeToken GetChangeToken()
{
var wildcardChangeToken = _fileProvider.Watch(_searchPattern);
if (_additionalFilesToTrack.Length == 0)
{
return wildcardChangeToken;
}
var changeTokens = new IChangeToken[_additionalFilesToTrack.Length + 1];
var changeTokens = new IChangeToken[_additionalFilesToTrack.Length + _searchPatterns.Length];
for (var i = 0; i < _additionalFilesToTrack.Length; i++)
{
changeTokens[i] = _fileProvider.Watch(_additionalFilesToTrack[i]);
}
changeTokens[changeTokens.Length - 1] = wildcardChangeToken;
for (var i = 0; i < _searchPatterns.Length; i++)
{
var wildcardChangeToken = _fileProvider.Watch(_searchPatterns[i]);
changeTokens[_additionalFilesToTrack.Length + i] = wildcardChangeToken;
}
return new CompositeChangeToken(changeTokens);
}
}

View File

@ -19,6 +19,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
private static readonly Action<ILogger, string, string, Exception> _handlerMethodExecuted;
private static readonly Action<ILogger, object, Exception> _pageFilterShortCircuit;
private static readonly Action<ILogger, string, string[], Exception> _malformedPageDirective;
private static readonly Action<ILogger, string, string, string, string, Exception> _unsupportedAreaPath;
private static readonly Action<ILogger, Type, Exception> _notMostEffectiveFilter;
private static readonly Action<ILogger, string, string, string, Exception> _beforeExecutingMethodOnFilter;
private static readonly Action<ILogger, string, string, string, Exception> _afterExecutingMethodOnFilter;
@ -61,6 +62,11 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
LogLevel.Trace,
2,
"{FilterType}: After executing {Method} on filter {Filter}.");
_unsupportedAreaPath = LoggerMessage.Define<string, string, string, string>(
LogLevel.Warning,
1,
"The page at '{FilePath}' is located under the area root directory '{AreaRootDirectory}' but does not follow the path format '{AreaRootDirectory}{RootDirectory}/Directory/FileName.cshtml");
}
public static void ExecutingHandlerMethod(this ILogger logger, PageContext context, HandlerMethodDescriptor handler, object[] arguments)
@ -133,5 +139,13 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
{
_notMostEffectiveFilter(logger, policyType, null);
}
public static void UnsupportedAreaPath(this ILogger logger, RazorPagesOptions options, string filePath)
{
if (logger.IsEnabled(LogLevel.Warning))
{
_unsupportedAreaPath(logger, filePath, options.AreaRootDirectory, options.AreaRootDirectory, options.RootDirectory, null);
}
}
}
}

View File

@ -2,17 +2,23 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Diagnostics;
using System.IO;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
{
public static class PageSelectorModel
{
private const string IndexFileName = "Index.cshtml";
private static readonly string IndexFileName = "Index" + RazorViewEngine.ViewExtension;
public static void PopulateDefaults(PageRouteModel model, string routeTemplate)
public static void PopulateDefaults(PageRouteModel model, string pageRoute, string routeTemplate)
{
model.RouteValues.Add("page", model.ViewEnginePath);
if (AttributeRouteModel.IsOverridePattern(routeTemplate))
{
throw new InvalidOperationException(string.Format(
@ -20,7 +26,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
model.RelativePath));
}
var selectorModel = CreateSelectorModel(model.ViewEnginePath, routeTemplate);
var selectorModel = CreateSelectorModel(pageRoute, routeTemplate);
model.Selectors.Add(selectorModel);
var fileName = Path.GetFileName(model.RelativePath);
@ -30,20 +36,62 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
// force outgoing routes to match to the path sans /Index.
selectorModel.AttributeRouteModel.SuppressLinkGeneration = true;
var parentDirectoryPath = model.ViewEnginePath;
var index = parentDirectoryPath.LastIndexOf('/');
if (index == -1)
{
parentDirectoryPath = string.Empty;
}
else
{
parentDirectoryPath = parentDirectoryPath.Substring(0, index);
}
var index = pageRoute.LastIndexOf('/');
var parentDirectoryPath = index == -1 ?
string.Empty :
pageRoute.Substring(0, index);
model.Selectors.Add(CreateSelectorModel(parentDirectoryPath, routeTemplate));
}
}
public static bool TryParseAreaPath(
RazorPagesOptions razorPagesOptions,
string path,
ILogger logger,
out (string areaName, string viewEnginePath, string pageRoute) result)
{
// path = "/Products/Pages/Manage/Home.cshtml"
// Result = ("Products", "/Manage/Home", "/Products/Manage/Home")
result = default;
Debug.Assert(path.StartsWith("/", StringComparison.Ordinal));
var areaEndIndex = path.IndexOf('/', startIndex: 1);
if (areaEndIndex == -1 || areaEndIndex == path.Length)
{
logger.UnsupportedAreaPath(razorPagesOptions, path);
return false;
}
// Normalize the pages root directory so that it has a
var normalizedPagesRootDirectory = razorPagesOptions.RootDirectory.TrimStart('/');
if (!normalizedPagesRootDirectory.EndsWith("/", StringComparison.Ordinal))
{
normalizedPagesRootDirectory += "/";
}
if (string.Compare(path, areaEndIndex + 1, normalizedPagesRootDirectory, 0, normalizedPagesRootDirectory.Length, StringComparison.OrdinalIgnoreCase) != 0)
{
logger.UnsupportedAreaPath(razorPagesOptions, path);
return false;
}
var areaName = path.Substring(1, areaEndIndex - 1);
var pagePathIndex = areaEndIndex + normalizedPagesRootDirectory.Length;
Debug.Assert(path.EndsWith(RazorViewEngine.ViewExtension), $"{path} does not end in extension '{RazorViewEngine.ViewExtension}'.");
var pageName = path.Substring(pagePathIndex, path.Length - pagePathIndex - RazorViewEngine.ViewExtension.Length);
var builder = new InplaceStringBuilder(areaEndIndex + pageName.Length);
builder.Append(path, 0, areaEndIndex);
builder.Append(pageName);
var pageRoute = builder.ToString();
result = (areaName, pageName, pageRoute);
return true;
}
private static SelectorModel CreateSelectorModel(string prefix, string template)
{
return new SelectorModel

View File

@ -27,15 +27,37 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
var rootDirectory = _pagesOptions.RootDirectory;
Debug.Assert(!string.IsNullOrEmpty(rootDirectory));
var defaultPageSearchPath = CombinePath(rootDirectory, "{1}/{0}");
var defaultPageSearchPath = CombinePath(rootDirectory, "{1}/{0}" + RazorViewEngine.ViewExtension);
options.PageViewLocationFormats.Add(defaultPageSearchPath);
// /Pages/Shared/{0}.cshtml
var pagesSharedDirectory = CombinePath(rootDirectory, "Shared/{0}");
var pagesSharedDirectory = CombinePath(rootDirectory, "Shared/{0}" + RazorViewEngine.ViewExtension);
options.PageViewLocationFormats.Add(pagesSharedDirectory);
options.PageViewLocationFormats.Add("/Views/Shared/{0}" + RazorViewEngine.ViewExtension);
Debug.Assert(!string.IsNullOrEmpty(_pagesOptions.AreaRootDirectory));
var areaDirectory = CombinePath(_pagesOptions.AreaRootDirectory, "{2}");
// Areas/{2}/Pages/
var areaPagesDirectory = CombinePath(areaDirectory, rootDirectory);
// Areas/{2}/Pages/{1}/{0}.cshtml
// Areas/{2}/Pages/Shared/{0}.cshtml
// Areas/{2}/Views/Shared/{0}.cshtml
// Pages/Shared/{0}.cshtml
// Views/Shared/{0}.cshtml
var areaSearchPath = CombinePath(areaPagesDirectory, "{1}/{0}" + RazorViewEngine.ViewExtension);
options.AreaPageViewLocationFormats.Add(areaSearchPath);
var areaPagesSharedSearchPath = CombinePath(areaPagesDirectory, "Shared/{0}" + RazorViewEngine.ViewExtension);
options.AreaPageViewLocationFormats.Add(areaPagesSharedSearchPath);
var areaViewsSharedSearchPath = CombinePath(areaDirectory, "Views/Shared/{0}" + RazorViewEngine.ViewExtension);
options.AreaPageViewLocationFormats.Add(areaViewsSharedSearchPath);
options.AreaPageViewLocationFormats.Add(pagesSharedDirectory);
options.AreaPageViewLocationFormats.Add("/Views/Shared/{0}" + RazorViewEngine.ViewExtension);
options.ViewLocationFormats.Add(pagesSharedDirectory);
options.AreaViewLocationFormats.Add(pagesSharedDirectory);
@ -44,12 +66,16 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
private static string CombinePath(string path1, string path2)
{
if (path1.EndsWith("/", StringComparison.Ordinal))
if (path1.EndsWith("/", StringComparison.Ordinal) || path2.StartsWith("/", StringComparison.Ordinal))
{
return path1 + path2 + RazorViewEngine.ViewExtension;
return path1 + path2;
}
else if (path1.EndsWith("/", StringComparison.Ordinal) && path2.StartsWith("/", StringComparison.Ordinal))
{
return path1 + path2.Substring(1);
}
return path1 + "/" + path2 + RazorViewEngine.ViewExtension;
return path1 + "/" + path2;
}
}
}

View File

@ -36,12 +36,21 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
}
public void OnProvidersExecuting(PageRouteModelProviderContext context)
{
AddPageModels(context);
if (_pagesOptions.EnableAreas)
{
AddAreaPageModels(context);
}
}
private void AddPageModels(PageRouteModelProviderContext context)
{
foreach (var item in _project.EnumerateItems(_pagesOptions.RootDirectory))
{
if (item.FileName.StartsWith("_"))
if (!IsRouteable(item))
{
// Pages like _ViewImports should not be routable.
continue;
}
@ -51,29 +60,71 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
continue;
}
if (IsAlreadyRegistered(context, item))
var routeModel = new PageRouteModel(
relativePath: item.CombinedPath,
viewEnginePath: item.FilePathWithoutExtension);
if (IsAlreadyRegistered(context, routeModel))
{
// The CompiledPageRouteModelProvider (or another provider) already registered a PageRoute for this path.
// Don't register a duplicate entry for this route.
continue;
}
var routeModel = new PageRouteModel(
relativePath: item.CombinedPath,
viewEnginePath: item.FilePathWithoutExtension);
PageSelectorModel.PopulateDefaults(routeModel, routeTemplate);
PageSelectorModel.PopulateDefaults(routeModel, routeModel.ViewEnginePath, routeTemplate);
context.RouteModels.Add(routeModel);
}
}
private bool IsAlreadyRegistered(PageRouteModelProviderContext context, RazorProjectItem projectItem)
private void AddAreaPageModels(PageRouteModelProviderContext context)
{
foreach (var item in _project.EnumerateItems(_pagesOptions.AreaRootDirectory))
{
if (!IsRouteable(item))
{
continue;
}
if (!PageDirectiveFeature.TryGetPageDirective(_logger, item, out var routeTemplate))
{
// .cshtml pages without @page are not RazorPages.
continue;
}
if (!PageSelectorModel.TryParseAreaPath(_pagesOptions, item.FilePath, _logger, out var areaResult))
{
continue;
}
var routeModel = new PageRouteModel(
relativePath: item.CombinedPath,
viewEnginePath: areaResult.viewEnginePath)
{
RouteValues =
{
["area"] = areaResult.areaName,
},
};
if (IsAlreadyRegistered(context, routeModel))
{
// The CompiledPageRouteModelProvider (or another provider) already registered a PageRoute for this path.
// Don't register a duplicate entry for this route.
continue;
}
PageSelectorModel.PopulateDefaults(routeModel, areaResult.pageRoute, routeTemplate);
context.RouteModels.Add(routeModel);
}
}
private bool IsAlreadyRegistered(PageRouteModelProviderContext context, PageRouteModel routeModel)
{
for (var i = 0; i < context.RouteModels.Count; i++)
{
var routeModel = context.RouteModels[i];
if (string.Equals(routeModel.ViewEnginePath, projectItem.FilePathWithoutExtension, StringComparison.OrdinalIgnoreCase) &&
string.Equals(routeModel.RelativePath, projectItem.CombinedPath, StringComparison.OrdinalIgnoreCase))
var existingRouteModel = context.RouteModels[i];
if (string.Equals(existingRouteModel.ViewEnginePath, routeModel.ViewEnginePath, StringComparison.OrdinalIgnoreCase) &&
string.Equals(existingRouteModel.RelativePath, existingRouteModel.RelativePath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
@ -81,5 +132,11 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
return false;
}
private static bool IsRouteable(RazorProjectItem item)
{
// Pages like _ViewImports should not be routable.
return !item.FileName.StartsWith("_", StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.Abstractions;
namespace Microsoft.AspNetCore.Mvc.RazorPages
{
[DebuggerDisplay("{" + nameof(ViewEnginePath) + "}")]
[DebuggerDisplay(nameof(DebuggerDisplayString))]
public class PageActionDescriptor : ActionDescriptor
{
/// <summary>
@ -60,5 +60,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages
base.DisplayName = value;
}
}
private string DebuggerDisplayString() => $"{{ViewEnginePath = {nameof(ViewEnginePath)}, RelativePath = {nameof(RelativePath)}}}";
}
}

View File

@ -2,7 +2,6 @@
// 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.Mvc.ApplicationModels;
namespace Microsoft.AspNetCore.Mvc.RazorPages
@ -13,6 +12,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages
public class RazorPagesOptions
{
private string _root = "/Pages";
private string _areasRoot = "/Areas";
/// <summary>
/// Gets a collection of <see cref="IPageConvention"/> instances that are applied during
@ -42,5 +42,41 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages
_root = value;
}
}
/// <summary>
/// Gets or sets a value that determines if areas are enabled for Razor Pages.
/// Defaults to <c>true</c>.
/// <para>
/// When enabled, any Razor Page under the directory structure <c>/{AreaRootDirectory}/AreaName/{RootDirectory}/</c>
/// will be associated with an area with the name <c>AreaName</c>.
/// <seealso cref="AreaRootDirectory"/>
/// <seealso cref="RootDirectory"/>
/// </para>
/// </summary>
public bool EnableAreas { get; set; }
/// <summary>
/// Application relative path used as the root of discovery for Razor Page files associated with areas.
/// Defaults to the <c>/Areas</c> directory under application root.
/// <seealso cref="EnableAreas" />
/// </summary>
public string AreaRootDirectory
{
get => _areasRoot;
set
{
if (string.IsNullOrEmpty(value))
{
throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(value));
}
if (value[0] != '/')
{
throw new ArgumentException(Resources.PathMustBeRootRelativePath, nameof(value));
}
_areasRoot = value;
}
}
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
@ -1556,6 +1557,49 @@ namespace Microsoft.AspNetCore.Mvc.Routing
"Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page.", ex.Message);
}
[Fact]
public void Page_UsesAreaValueFromRouteValueIfSpecified()
{
// Arrange
UrlRouteContext actual = null;
var routeData = new RouteData
{
Values =
{
{ "page", "ambient-page" },
{ "area", "ambient-area" },
}
};
var actionContext = new ActionContext
{
RouteData = routeData,
};
var urlHelper = CreateMockUrlHelper(actionContext);
urlHelper.Setup(h => h.RouteUrl(It.IsAny<UrlRouteContext>()))
.Callback((UrlRouteContext context) => actual = context);
// Act
string page = null;
urlHelper.Object.Page(page, values: new { area = "specified-area" });
// Assert
urlHelper.Verify();
Assert.NotNull(actual);
Assert.Null(actual.RouteName);
Assert.Collection(Assert.IsType<RouteValueDictionary>(actual.Values).OrderBy(v => v.Key),
value =>
{
Assert.Equal("area", value.Key);
Assert.Equal("specified-area", value.Value);
},
value =>
{
Assert.Equal("page", value.Key);
Assert.Equal("ambient-page", value.Value);
});
}
private static Mock<IUrlHelper> CreateMockUrlHelper(ActionContext context = null)
{
if (context == null)

View File

@ -280,7 +280,7 @@ Hello from page";
public async Task Pages_ReturnsFromPagesSharedDirectory()
{
// Arrange
var expected = "Hello from Pages/Shared";
var expected = "Hello from /Pages/Shared/";
// Act
var response = await Client.GetStringAsync("/SearchInPages");
@ -288,5 +288,87 @@ Hello from page";
// Assert
Assert.Equal(expected, response.Trim());
}
[Fact]
public async Task PagesInAreas_Work()
{
// Arrange
var expected = "Hello from a page in Accounts area";
// Act
var response = await Client.GetStringAsync("/Accounts/About");
// Assert
Assert.Equal(expected, response.Trim());
}
[Fact]
public async Task PagesInAreas_CanHaveRouteTemplates()
{
// Arrange
var expected = "The id is 42";
// Act
var response = await Client.GetStringAsync("/Accounts/PageWithRouteTemplate/42");
// Assert
Assert.Equal(expected, response.Trim());
}
[Fact]
public async Task PagesInAreas_CanGenerateLinksToControllersAndPages()
{
// Arrange
var expected =
@"<a href=""/Accounts/Manage/RenderPartials"">Link inside area</a>
<a href=""/Products/List/old/20"">Link to external area</a>
<a href=""/Accounts"">Link to area action</a>
<a href=""/Admin"">Link to non-area page</a>";
// Act
var response = await Client.GetStringAsync("/Accounts/PageWithLinks");
// Assert
Assert.Equal(expected, response.Trim());
}
[Fact]
public async Task PagesInAreas_CanGenerateRelativeLinks()
{
// Arrange
var expected =
@"<a href=""/Accounts/PageWithRouteTemplate/1"">Parent directory</a>
<a href=""/Accounts/Manage/RenderPartials"">Sibling directory</a>
<a href=""/Products/List"">Go back to root of different area</a>";
// Act
var response = await Client.GetStringAsync("/Accounts/RelativeLinks");
// Assert
Assert.Equal(expected, response.Trim());
}
[Fact]
public async Task PagesInAreas_CanDiscoverViewsFromAreaAndSharedDirectories()
{
// Arrange
var expected =
@"Layout in /Views/Shared
Partial in /Areas/Accounts/Pages/Manage/
Partial in /Areas/Accounts/Pages/
Partial in /Areas/Accounts/Pages/
Partial in /Areas/Accounts/Views/Shared/
Hello from /Pages/Shared/";
// Act
var response = await Client.GetStringAsync("/Accounts/Manage/RenderPartials");
// Assert
Assert.Equal(expected, response.Trim());
}
}
}

View File

@ -142,6 +142,65 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
Assert.Equal(expected, actual.ToArray());
}
[Theory]
[InlineData("/Index", new [] { "/Areas/{2}/Pages/{0}.cshtml" })]
[InlineData("/Manage/User", new [] { "/Areas/{2}/Pages/Manage/{0}.cshtml", "/Areas/{2}/Pages/{0}.cshtml" })]
public void ExpandLocations_ExpandsAreaPaths(string pageName, string[] expected)
{
// Arrange
var context = CreateContext(pageName: pageName);
var locations = new[]
{
"/Areas/{2}/Pages/{1}/{0}.cshtml",
};
var expander = new PageViewLocationExpander();
// Act
var actual = expander.ExpandViewLocations(context, locations);
// Assert
Assert.Equal(expected, actual.ToArray());
}
[Fact]
public void ExpandLocations_ExpandsAreaPaths_MultipleLocations()
{
// Arrange
var context = CreateContext(pageName: "/Customers/Edit");
var locations = new[]
{
"/Areas/{2}/Pages/{1}/{0}.cshtml",
"/Areas/{2}/Pages/Shared/{0}.cshtml",
"/Areas/{2}/Views/Shared/{0}.cshtml",
"/Areas/{2}/Pages/Shared/{0}.cshtml",
"/User/Customized/{1}/{0}.cshtml",
"/Views/Shared/{0}.cshtml",
"/Pages/Shared/{0}.cshtml",
};
var expected = new[]
{
"/Areas/{2}/Pages/Customers/{0}.cshtml",
"/Areas/{2}/Pages/{0}.cshtml",
"/Areas/{2}/Pages/Shared/{0}.cshtml",
"/Areas/{2}/Views/Shared/{0}.cshtml",
"/Areas/{2}/Pages/Shared/{0}.cshtml",
"/User/Customized/Customers/{0}.cshtml",
"/User/Customized/{0}.cshtml",
"/Views/Shared/{0}.cshtml",
"/Pages/Shared/{0}.cshtml",
};
var expander = new PageViewLocationExpander();
// Act
var actual = expander.ExpandViewLocations(context, locations);
// Assert
Assert.Equal(expected, actual.ToArray());
}
private ViewLocationExpanderContext CreateContext(string viewName = "_LoginPartial.cshtml", string pageName = null)
{
var actionContext = new ActionContext
@ -158,7 +217,6 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
isMainPage: true)
{
Values = new Dictionary<string, string>(),
};
}
}

View File

@ -3,10 +3,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Razor.Compilation;
using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
@ -37,6 +39,12 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
Assert.Equal("/About", result.ViewEnginePath);
Assert.Collection(result.Selectors,
selector => Assert.Equal("About", selector.AttributeRouteModel.Template));
Assert.Collection(result.RouteValues.OrderBy(k => k.Key),
kvp =>
{
Assert.Equal("page", kvp.Key);
Assert.Equal("/About", kvp.Value);
});
},
result =>
{
@ -44,6 +52,126 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
Assert.Equal("/Home", result.ViewEnginePath);
Assert.Collection(result.Selectors,
selector => Assert.Equal("Home/some-prefix", selector.AttributeRouteModel.Template));
Assert.Collection(result.RouteValues.OrderBy(k => k.Key),
kvp =>
{
Assert.Equal("page", kvp.Key);
Assert.Equal("/Home", kvp.Value);
});
});
}
[Fact]
public void OnProvidersExecuting_AddsModelsForCompiledAreaPages()
{
// Arrange
var descriptors = new[]
{
GetDescriptor("/Features/Products/Files/About.cshtml"),
GetDescriptor("/Features/Products/Files/Manage/Index.cshtml"),
GetDescriptor("/Features/Products/Files/Manage/Edit.cshtml", "{id}"),
};
var options = new RazorPagesOptions
{
EnableAreas = true,
AreaRootDirectory = "/Features",
RootDirectory = "/Files",
};
var provider = new TestCompiledPageRouteModelProvider(descriptors, options);
var context = new PageRouteModelProviderContext();
// Act
provider.OnProvidersExecuting(context);
// Assert
Assert.Collection(context.RouteModels,
result =>
{
Assert.Equal("/Features/Products/Files/About.cshtml", result.RelativePath);
Assert.Equal("/About", result.ViewEnginePath);
Assert.Collection(result.Selectors,
selector => Assert.Equal("Products/About", selector.AttributeRouteModel.Template));
Assert.Collection(result.RouteValues.OrderBy(k => k.Key),
kvp =>
{
Assert.Equal("area", kvp.Key);
Assert.Equal("Products", kvp.Value);
},
kvp =>
{
Assert.Equal("page", kvp.Key);
Assert.Equal("/About", kvp.Value);
});
},
result =>
{
Assert.Equal("/Features/Products/Files/Manage/Index.cshtml", result.RelativePath);
Assert.Equal("/Manage/Index", result.ViewEnginePath);
Assert.Collection(result.Selectors,
selector => Assert.Equal("Products/Manage/Index", selector.AttributeRouteModel.Template),
selector => Assert.Equal("Products/Manage", selector.AttributeRouteModel.Template));
Assert.Collection(result.RouteValues.OrderBy(k => k.Key),
kvp =>
{
Assert.Equal("area", kvp.Key);
Assert.Equal("Products", kvp.Value);
},
kvp =>
{
Assert.Equal("page", kvp.Key);
Assert.Equal("/Manage/Index", kvp.Value);
});
},
result =>
{
Assert.Equal("/Features/Products/Files/Manage/Edit.cshtml", result.RelativePath);
Assert.Equal("/Manage/Edit", result.ViewEnginePath);
Assert.Collection(result.Selectors,
selector => Assert.Equal("Products/Manage/Edit/{id}", selector.AttributeRouteModel.Template));
Assert.Collection(result.RouteValues.OrderBy(k => k.Key),
kvp =>
{
Assert.Equal("area", kvp.Key);
Assert.Equal("Products", kvp.Value);
},
kvp =>
{
Assert.Equal("page", kvp.Key);
Assert.Equal("/Manage/Edit", kvp.Value);
});
});
}
[Fact]
public void OnProvidersExecuting_DoesNotAddsModelsForAreaPages_IfFeatureIsDisabled()
{
// Arrange
var descriptors = new[]
{
GetDescriptor("/Pages/About.cshtml"),
GetDescriptor("/Areas/Accounts/Pages/Home.cshtml"),
};
var options = new RazorPagesOptions { EnableAreas = false };
var provider = new TestCompiledPageRouteModelProvider(descriptors, options);
var context = new PageRouteModelProviderContext();
// Act
provider.OnProvidersExecuting(context);
// Assert
Assert.Collection(context.RouteModels,
result =>
{
Assert.Equal("/Pages/About.cshtml", result.RelativePath);
Assert.Equal("/About", result.ViewEnginePath);
Assert.Collection(result.Selectors,
selector => Assert.Equal("About", selector.AttributeRouteModel.Template));
Assert.Collection(result.RouteValues.OrderBy(k => k.Key),
kvp =>
{
Assert.Equal("page", kvp.Key);
Assert.Equal("/About", kvp.Value);
});
});
}
@ -149,7 +277,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
private readonly IEnumerable<CompiledViewDescriptor> _descriptors;
public TestCompiledPageRouteModelProvider(IEnumerable<CompiledViewDescriptor> descriptors, RazorPagesOptions options)
: base(new ApplicationPartManager(), Options.Create(options))
: base(new ApplicationPartManager(), Options.Create(options), NullLoggerFactory.Instance)
{
_descriptors = descriptors;
}

View File

@ -19,6 +19,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
{
// Arrange
var fileProvider = new Mock<IFileProvider>();
fileProvider.Setup(f => f.Watch(It.IsAny<string>()))
.Returns(Mock.Of<IChangeToken>());
var accessor = Mock.Of<IRazorViewEngineFileProviderAccessor>(a => a.FileProvider == fileProvider.Object);
var templateEngine = new RazorTemplateEngine(
@ -41,6 +43,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
{
// Arrange
var fileProvider = new Mock<IFileProvider>();
fileProvider.Setup(f => f.Watch(It.IsAny<string>()))
.Returns(Mock.Of<IChangeToken>());
var accessor = Mock.Of<IRazorViewEngineFileProviderAccessor>(a => a.FileProvider == fileProvider.Object);
var templateEngine = new RazorTemplateEngine(
@ -59,7 +63,57 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
}
[Fact]
public void GetChangeToken_WatchesViewImportsOutsidePagesRoot()
public void GetChangeToken_WatchesFilesUnderAreaRoot()
{
// Arrange
var fileProvider = new Mock<IFileProvider>();
fileProvider.Setup(f => f.Watch(It.IsAny<string>()))
.Returns(Mock.Of<IChangeToken>());
var accessor = Mock.Of<IRazorViewEngineFileProviderAccessor>(a => a.FileProvider == fileProvider.Object);
var templateEngine = new RazorTemplateEngine(
RazorEngine.Create(),
new FileProviderRazorProject(accessor));
var options = Options.Create(new RazorPagesOptions { EnableAreas = true });
var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options);
// Act
var changeToken = changeProvider.GetChangeToken();
// Assert
fileProvider.Verify(f => f.Watch("/Areas/**/*.cshtml"));
}
[Theory]
[InlineData("/areas-base-dir")]
[InlineData("/areas-base-dir/")]
public void GetChangeToken_WatchesFilesUnderCustomAreaRoot(string rootDirectory)
{
// Arrange
var fileProvider = new Mock<IFileProvider>();
fileProvider.Setup(f => f.Watch(It.IsAny<string>()))
.Returns(Mock.Of<IChangeToken>());
var accessor = Mock.Of<IRazorViewEngineFileProviderAccessor>(a => a.FileProvider == fileProvider.Object);
var templateEngine = new RazorTemplateEngine(
RazorEngine.Create(),
new FileProviderRazorProject(accessor));
var options = Options.Create(new RazorPagesOptions
{
EnableAreas = true,
AreaRootDirectory = rootDirectory,
});
var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options);
// Act
var changeToken = changeProvider.GetChangeToken();
// Assert
fileProvider.Verify(f => f.Watch("/areas-base-dir/**/*.cshtml"));
}
[Fact]
public void GetChangeToken_WatchesViewImportsOutsidePagesRoot_WhenPagesRootIsNested()
{
// Arrange
var fileProvider = new TestFileProvider();
@ -81,5 +135,55 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
changeToken => Assert.Same(fileProvider.GetChangeToken("/_ViewImports.cshtml"), changeToken),
changeToken => Assert.Same(fileProvider.GetChangeToken("/dir1/dir2/**/*.cshtml"), changeToken));
}
[Fact]
public void GetChangeToken_WatchesViewImportsOutsidePagesRoot_WhenAreaPagesRootIsNested()
{
// Arrange
var fileProvider = new TestFileProvider();
var accessor = Mock.Of<IRazorViewEngineFileProviderAccessor>(a => a.FileProvider == fileProvider);
var templateEngine = new RazorTemplateEngine(
RazorEngine.Create(),
new FileProviderRazorProject(accessor));
templateEngine.Options.ImportsFileName = "_ViewImports.cshtml";
var options = Options.Create(new RazorPagesOptions());
options.Value.RootDirectory = "/dir1/dir2";
options.Value.AreaRootDirectory = "/dir3/dir4";
options.Value.EnableAreas = true;
var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options);
// Act & Assert
var compositeChangeToken = Assert.IsType<CompositeChangeToken>(changeProvider.GetChangeToken());
Assert.Collection(compositeChangeToken.ChangeTokens,
changeToken => Assert.Same(fileProvider.GetChangeToken("/dir1/_ViewImports.cshtml"), changeToken),
changeToken => Assert.Same(fileProvider.GetChangeToken("/_ViewImports.cshtml"), changeToken),
changeToken => Assert.Same(fileProvider.GetChangeToken("/dir3/_ViewImports.cshtml"), changeToken),
changeToken => Assert.Same(fileProvider.GetChangeToken("/dir1/dir2/**/*.cshtml"), changeToken),
changeToken => Assert.Same(fileProvider.GetChangeToken("/dir3/dir4/**/*.cshtml"), changeToken));
}
[Fact]
public void GetChangeToken_WatchesViewImportsOutsidePagesRoot_WhenAreaFeatureIsDisabled()
{
// Arrange
var fileProvider = new TestFileProvider();
var accessor = Mock.Of<IRazorViewEngineFileProviderAccessor>(a => a.FileProvider == fileProvider);
var templateEngine = new RazorTemplateEngine(
RazorEngine.Create(),
new FileProviderRazorProject(accessor));
templateEngine.Options.ImportsFileName = "_ViewImports.cshtml";
var options = Options.Create(new RazorPagesOptions { EnableAreas = false });
var changeProvider = new PageActionDescriptorChangeProvider(templateEngine, accessor, options);
// Act & Assert
var compositeChangeToken = Assert.IsType<CompositeChangeToken>(changeProvider.GetChangeToken());
Assert.Collection(compositeChangeToken.ChangeTokens,
changeToken => Assert.Same(fileProvider.GetChangeToken("/_ViewImports.cshtml"), changeToken),
changeToken => Assert.Same(fileProvider.GetChangeToken("/Pages/**/*.cshtml"), changeToken));
}
}
}

View File

@ -0,0 +1,89 @@
// 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.Extensions.Logging.Abstractions;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
{
public class PageSelectorModelTest
{
[Theory]
[InlineData("/Areas/About.cshtml")]
[InlineData("/Areas/MyArea/Index.cshtml")]
public void TryParseAreaPath_ReturnsFalse_IfPathDoesNotConform(string path)
{
// Arrange
var options = new RazorPagesOptions();
// Act
var success = PageSelectorModel.TryParseAreaPath(options, path, NullLogger.Instance, out _);
// Assert
Assert.False(success);
}
[Theory]
[InlineData("/MyArea/Views/About.cshtml")]
[InlineData("/MyArea/SubDir/Pages/Index.cshtml")]
[InlineData("/MyArea/NotPages/SubDir/About.cshtml")]
public void TryParseAreaPath_ReturnsFalse_IfPathDoesNotBelongToRootDirectory(string path)
{
// Arrange
var options = new RazorPagesOptions();
// Act
var success = PageSelectorModel.TryParseAreaPath(options, path, NullLogger.Instance, out _);
// Assert
Assert.False(success);
}
[Theory]
[InlineData("/MyArea/Pages/Index.cshtml", "MyArea", "/Index", "/MyArea/Index")]
[InlineData("/Accounts/Pages/Manage/Edit.cshtml", "Accounts", "/Manage/Edit", "/Accounts/Manage/Edit")]
public void TryParseAreaPath_ParsesAreaPath(
string path,
string expectedArea,
string expectedViewEnginePath,
string expectedRoute)
{
// Arrange
var options = new RazorPagesOptions();
// Act
var success = PageSelectorModel.TryParseAreaPath(options, path, NullLogger.Instance, out var result);
// Assert
Assert.True(success);
Assert.Equal(expectedArea, result.areaName);
Assert.Equal(expectedViewEnginePath, result.viewEnginePath);
Assert.Equal(expectedRoute, result.pageRoute);
}
[Theory]
[InlineData("/MyArea/Dir1/Dir2/Index.cshtml", "MyArea", "/Index", "/MyArea/Index")]
[InlineData("/Accounts/Dir1/Dir2/Manage/Edit.cshtml", "Accounts", "/Manage/Edit", "/Accounts/Manage/Edit")]
public void TryParseAreaPath_ParsesAreaPath_WithMultiLevelRootDirectory(
string path,
string expectedArea,
string expectedViewEnginePath,
string expectedRoute)
{
// Arrange
var options = new RazorPagesOptions
{
RootDirectory = "/Dir1/Dir2"
};
// Act
var success = PageSelectorModel.TryParseAreaPath(options, path, NullLogger.Instance, out var result);
// Assert
Assert.True(success);
Assert.Equal(expectedArea, result.areaName);
Assert.Equal(expectedViewEnginePath, result.viewEnginePath);
Assert.Equal(expectedRoute, result.pageRoute);
}
}
}

View File

@ -62,6 +62,60 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
Assert.Equal(expected, viewEngineOptions.PageViewLocationFormats);
}
[Fact]
public void Configure_AddsAreaPageViewLocationFormats()
{
// Arrange
var expected = new[]
{
"/Areas/{2}/Pages/{1}/{0}.cshtml",
"/Areas/{2}/Pages/Shared/{0}.cshtml",
"/Areas/{2}/Views/Shared/{0}.cshtml",
"/Pages/Shared/{0}.cshtml",
"/Views/Shared/{0}.cshtml",
};
var razorPagesOptions = new RazorPagesOptions();
var viewEngineOptions = GetViewEngineOptions();
var setup = new RazorPagesRazorViewEngineOptionsSetup(
Options.Create(razorPagesOptions));
// Act
setup.Configure(viewEngineOptions);
// Assert
Assert.Equal(expected, viewEngineOptions.AreaPageViewLocationFormats);
}
[Fact]
public void Configure_WithCustomRoots_AddsAreaPageViewLocationFormats()
{
// Arrange
var expected = new[]
{
"/Features/{2}/RazorFiles/{1}/{0}.cshtml",
"/Features/{2}/RazorFiles/Shared/{0}.cshtml",
"/Features/{2}/Views/Shared/{0}.cshtml",
"/RazorFiles/Shared/{0}.cshtml",
"/Views/Shared/{0}.cshtml",
};
var razorPagesOptions = new RazorPagesOptions
{
AreaRootDirectory = "/Features",
RootDirectory = "/RazorFiles/",
};
var viewEngineOptions = GetViewEngineOptions();
var setup = new RazorPagesRazorViewEngineOptionsSetup(
Options.Create(razorPagesOptions));
// Act
setup.Configure(viewEngineOptions);
// Assert
Assert.Equal(expected, viewEngineOptions.AreaPageViewLocationFormats);
}
[Fact]
public void Configure_AddsSharedPagesDirectoryToViewLocationFormats()
{

View File

@ -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.Linq;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.FileProviders;
@ -42,6 +43,131 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
Assert.Equal("/Pages/Home", model.ViewEnginePath);
Assert.Collection(model.Selectors,
selector => Assert.Equal("Pages/Home", selector.AttributeRouteModel.Template));
Assert.Collection(model.RouteValues.OrderBy(k => k.Key),
kvp =>
{
Assert.Equal("page", kvp.Key);
Assert.Equal("/Pages/Home", kvp.Value);
});
});
}
[Fact]
public void OnProvidersExecuting_AddsPagesUnderAreas()
{
// Arrange
var fileProvider = new TestFileProvider();
var file1 = fileProvider.AddFile("Categories.cshtml", "@page");
var file2 = fileProvider.AddFile("Index.cshtml", "@page");
var file3 = fileProvider.AddFile("List.cshtml", "@page \"{sortOrder?}\"");
var file4 = fileProvider.AddFile("_ViewStart.cshtml", "@page");
var manageDir = fileProvider.AddDirectoryContent("/Areas/Products/Pages/Manage", new[] { file1 });
var pagesDir = fileProvider.AddDirectoryContent("/Areas/Products/Pages", new IFileInfo[] { manageDir, file2, file3, file4 });
var productsDir = fileProvider.AddDirectoryContent("/Areas/Products", new[] { pagesDir });
var areasDir = fileProvider.AddDirectoryContent("/Areas", new[] { productsDir });
var rootDir = fileProvider.AddDirectoryContent("/", new[] { areasDir });
var project = new TestRazorProject(fileProvider);
var optionsManager = Options.Create(new RazorPagesOptions { EnableAreas = true });
var provider = new RazorProjectPageRouteModelProvider(project, optionsManager, NullLoggerFactory.Instance);
var context = new PageRouteModelProviderContext();
// Act
provider.OnProvidersExecuting(context);
// Assert
Assert.Collection(context.RouteModels,
model =>
{
Assert.Equal("/Areas/Products/Pages/Manage/Categories.cshtml", model.RelativePath);
Assert.Equal("/Manage/Categories", model.ViewEnginePath);
Assert.Collection(model.Selectors,
selector => Assert.Equal("Products/Manage/Categories", selector.AttributeRouteModel.Template));
Assert.Collection(model.RouteValues.OrderBy(k => k.Key),
kvp =>
{
Assert.Equal("area", kvp.Key);
Assert.Equal("Products", kvp.Value);
},
kvp =>
{
Assert.Equal("page", kvp.Key);
Assert.Equal("/Manage/Categories", kvp.Value);
});
},
model =>
{
Assert.Equal("/Areas/Products/Pages/Index.cshtml", model.RelativePath);
Assert.Equal("/Index", model.ViewEnginePath);
Assert.Collection(model.Selectors,
selector => Assert.Equal("Products/Index", selector.AttributeRouteModel.Template),
selector => Assert.Equal("Products", selector.AttributeRouteModel.Template));
Assert.Collection(model.RouteValues.OrderBy(k => k.Key),
kvp =>
{
Assert.Equal("area", kvp.Key);
Assert.Equal("Products", kvp.Value);
},
kvp =>
{
Assert.Equal("page", kvp.Key);
Assert.Equal("/Index", kvp.Value);
});
},
model =>
{
Assert.Equal("/Areas/Products/Pages/List.cshtml", model.RelativePath);
Assert.Equal("/List", model.ViewEnginePath);
Assert.Collection(model.Selectors,
selector => Assert.Equal("Products/List/{sortOrder?}", selector.AttributeRouteModel.Template));
Assert.Collection(model.RouteValues.OrderBy(k => k.Key),
kvp =>
{
Assert.Equal("area", kvp.Key);
Assert.Equal("Products", kvp.Value);
},
kvp =>
{
Assert.Equal("page", kvp.Key);
Assert.Equal("/List", kvp.Value);
});
});
}
[Fact]
public void OnProvidersExecuting_DoesNotAddPagesUnderAreas_WhenFeatureIsDisabled()
{
// Arrange
var fileProvider = new TestFileProvider();
var file1 = fileProvider.AddFile("Categories.cshtml", "@page");
var file2 = fileProvider.AddFile("Index.cshtml", "@page");
var file3 = fileProvider.AddFile("List.cshtml", "@page \"{sortOrder?}\"");
var file4 = fileProvider.AddFile("About.cshtml", "@page");
var manageDir = fileProvider.AddDirectoryContent("/Areas/Products/Pages/Manage", new[] { file1 });
var areaPagesDir = fileProvider.AddDirectoryContent("/Areas/Products/Pages", new IFileInfo[] { manageDir, file2, file3, });
var productsDir = fileProvider.AddDirectoryContent("/Areas/Products", new[] { areaPagesDir });
var areasDir = fileProvider.AddDirectoryContent("/Areas", new[] { productsDir });
var pagesDir = fileProvider.AddDirectoryContent("/Pages", new[] { file4 });
var rootDir = fileProvider.AddDirectoryContent("/", new[] { areasDir, pagesDir });
var project = new TestRazorProject(fileProvider);
var optionsManager = Options.Create(new RazorPagesOptions { EnableAreas = false });
var provider = new RazorProjectPageRouteModelProvider(project, optionsManager, NullLoggerFactory.Instance);
var context = new PageRouteModelProviderContext();
// Act
provider.OnProvidersExecuting(context);
// Assert
Assert.Collection(context.RouteModels,
model =>
{
Assert.Equal("/Pages/About.cshtml", model.RelativePath);
Assert.Equal("/About", model.ViewEnginePath);
Assert.Collection(model.Selectors,
selector => Assert.Equal("About", selector.AttributeRouteModel.Template));
});
}

View File

@ -417,6 +417,68 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
generator.Verify();
}
[Fact]
public async Task ProcessAsync_AddsAreaToRouteValuesAndCallsPageLinkWithExpectedParameters()
{
// Arrange
var context = new TagHelperContext(
tagName: "a",
allAttributes: new TagHelperAttributeList(
Enumerable.Empty<TagHelperAttribute>()),
items: new Dictionary<object, object>(),
uniqueId: "test");
var output = new TagHelperOutput(
"a",
attributes: new TagHelperAttributeList(),
getChildContentAsync: (useCachedResult, encoder) =>
{
var tagHelperContent = new DefaultTagHelperContent();
tagHelperContent.SetContent("Something");
return Task.FromResult<TagHelperContent>(tagHelperContent);
});
output.Content.SetContent(string.Empty);
var generator = new Mock<IHtmlGenerator>();
generator
.Setup(mock => mock.GeneratePageLink(
It.IsAny<ViewContext>(),
string.Empty,
"/User/Home/Index",
"page-handler",
"http",
"contoso.com",
"hello=world",
It.IsAny<object>(),
null))
.Callback((ViewContext v, string linkText, string pageName, string pageHandler, string protocol, string hostname, string fragment, object routeValues, object htmlAttributes) =>
{
var rvd = Assert.IsType<RouteValueDictionary>(routeValues);
Assert.Collection(rvd.OrderBy(item => item.Key),
item =>
{
Assert.Equal("area", item.Key);
Assert.Equal("test-area", item.Value);
});
})
.Returns(new TagBuilder("a"))
.Verifiable();
var anchorTagHelper = new AnchorTagHelper(generator.Object)
{
Page = "/User/Home/Index",
Area = "test-area",
PageHandler = "page-handler",
Fragment = "hello=world",
Host = "contoso.com",
Protocol = "http",
};
// Act
await anchorTagHelper.ProcessAsync(context, output);
// Assert
generator.Verify();
}
[Theory]
[InlineData("Action")]
[InlineData("Controller")]

View File

@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
@ -441,6 +440,71 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
Assert.Empty(output.Content.GetContent());
}
[Fact]
public async Task ProcessAsync_WithPageAndArea_CallsUrlHelperWithExpectedValues()
{
// Arrange
var context = new TagHelperContext(
tagName: "form-action",
allAttributes: new TagHelperAttributeList(),
items: new Dictionary<object, object>(),
uniqueId: "test");
var output = new TagHelperOutput(
"submit",
attributes: new TagHelperAttributeList(),
getChildContentAsync: (useCachedResult, encoder) =>
{
return Task.FromResult<TagHelperContent>(new DefaultTagHelperContent());
});
var urlHelper = new Mock<IUrlHelper>();
urlHelper
.Setup(mock => mock.RouteUrl(It.IsAny<UrlRouteContext>()))
.Callback<UrlRouteContext>(routeContext =>
{
var rvd = Assert.IsType<RouteValueDictionary>(routeContext.Values);
Assert.Collection(
rvd.OrderBy(item => item.Key),
item =>
{
Assert.Equal("area", item.Key);
Assert.Equal("test-area", item.Value);
},
item =>
{
Assert.Equal("page", item.Key);
Assert.Equal("/my-page", item.Value);
});
})
.Returns("admin/dashboard/index")
.Verifiable();
var viewContext = new ViewContext
{
RouteData = new RouteData(),
};
urlHelper.SetupGet(h => h.ActionContext)
.Returns(viewContext);
var urlHelperFactory = new Mock<IUrlHelperFactory>(MockBehavior.Strict);
urlHelperFactory
.Setup(f => f.GetUrlHelper(viewContext))
.Returns(urlHelper.Object);
var tagHelper = new FormActionTagHelper(urlHelperFactory.Object)
{
Area = "test-area",
Page = "/my-page",
ViewContext = viewContext,
};
// Act
await tagHelper.ProcessAsync(context, output);
// Assert
urlHelper.Verify();
}
[Theory]
[InlineData("button", "Action")]
[InlineData("button", "Controller")]

View File

@ -900,6 +900,64 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
generator.Verify();
}
[Fact]
public async Task ProcessAsync_WithPageAndArea_InvokesGeneratePageForm()
{
// Arrange
var viewContext = CreateViewContext();
var context = new TagHelperContext(
tagName: "form",
allAttributes: new TagHelperAttributeList(
Enumerable.Empty<TagHelperAttribute>()),
items: new Dictionary<object, object>(),
uniqueId: "test");
var output = new TagHelperOutput(
"form",
attributes: new TagHelperAttributeList(),
getChildContentAsync: (useCachedResult, encoder) =>
{
var tagHelperContent = new DefaultTagHelperContent();
tagHelperContent.SetContent("Something");
return Task.FromResult<TagHelperContent>(tagHelperContent);
});
var generator = new Mock<IHtmlGenerator>(MockBehavior.Strict);
generator
.Setup(mock => mock.GeneratePageForm(
viewContext,
"/Home/Admin/Post",
"page-handler",
It.IsAny<object>(),
"hello-world",
null,
null))
.Callback((ViewContext _, string pageName, string pageHandler, object routeValues, string fragment, string method, object htmlAttributes) =>
{
var rvd = Assert.IsType<RouteValueDictionary>(routeValues);
Assert.Collection(
rvd.OrderBy(item => item.Key),
item =>
{
Assert.Equal("area", item.Key);
Assert.Equal("test-area", item.Value);
});
})
.Returns(new TagBuilder("form"))
.Verifiable();
var formTagHelper = new FormTagHelper(generator.Object)
{
Antiforgery = false,
ViewContext = viewContext,
Page = "/Home/Admin/Post",
PageHandler = "page-handler",
Fragment = "hello-world",
Area = "test-area",
};
// Act & Assert
await formTagHelper.ProcessAsync(context, output);
generator.Verify();
}
[Theory]
[InlineData(true, "<input />")]
[InlineData(false, "")]

View File

@ -7,10 +7,17 @@ namespace Microsoft.Extensions.Primitives
{
public class TestFileChangeToken : IChangeToken
{
public TestFileChangeToken(string filter = "")
{
Filter = filter;
}
public bool ActiveChangeCallbacks => false;
public bool HasChanged { get; set; }
public string Filter { get; }
public IDisposable RegisterChangeCallback(Action<object> callback, object state)
{
return new NullDisposable();
@ -22,5 +29,7 @@ namespace Microsoft.Extensions.Primitives
{
}
}
public override string ToString() => Filter;
}
}

View File

@ -76,7 +76,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor
public virtual TestFileChangeToken AddChangeToken(string filter)
{
var changeToken = new TestFileChangeToken();
var changeToken = new TestFileChangeToken(filter);
_fileTriggers[filter] = changeToken;
return changeToken;
@ -84,10 +84,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor
public virtual IChangeToken Watch(string filter)
{
TestFileChangeToken changeToken;
if (!_fileTriggers.TryGetValue(filter, out changeToken) || changeToken.HasChanged)
if (!_fileTriggers.TryGetValue(filter, out var changeToken) || changeToken.HasChanged)
{
changeToken = new TestFileChangeToken();
changeToken = new TestFileChangeToken(filter);
_fileTriggers[filter] = changeToken;
}

View File

@ -0,0 +1,14 @@
// 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.Mvc;
namespace RazorPagesWebSite
{
[Area("Accounts")]
public class HomeController : Controller
{
public IActionResult Index() => View();
}
}

View File

@ -0,0 +1,2 @@
@page
Hello from a page in Accounts area

View File

@ -0,0 +1,9 @@
@page
@{
Layout = "_GlobalLayout";
}
<partial name="_PartialInManage" />
<partial name="_PartialInAreaPagesRoot" />
<partial name="../_PartialInAreaPagesRoot" />
<partial name="_PartialInAreasSharedViews" />
<partial name="_FileInShared" />

View File

@ -0,0 +1 @@
Partial in /Areas/Accounts/Pages/Manage/

View File

@ -0,0 +1,6 @@
@page
<a asp-page="/Manage/RenderPartials">Link inside area</a>
<a asp-page="/List" asp-area="Products" asp-route-sort="old" asp-route-top="20">Link to external area</a>
<a asp-controller="Home" asp-action="Index">Link to area action</a>
<a asp-page="/Admin/Index" asp-area="">Link to non-area page</a>

View File

@ -0,0 +1,7 @@
@page "{id}"
@functions
{
[BindProperty(SupportsGet = true)]
public string Id { get; set; }
}
The id is @Id

View File

@ -0,0 +1,4 @@
@page
<a asp-page="../PageWithRouteTemplate" asp-route-id="1">Parent directory</a>
<a asp-page="../Manage/RenderPartials">Sibling directory</a>
<a asp-page="../List" asp-area="Products">Go back to root of different area</a>

View File

@ -0,0 +1 @@
Partial in /Areas/Accounts/Pages/

View File

@ -0,0 +1,3 @@
<a asp-page="/PageWithRouteTemplate" asp-route-id="10">Link to page inside current area</a>
<a asp-area="Products" asp-page="/List" asp-route-sort="best">Link to page in different area</a>
<a asp-page="/Routes/Sibling">Link to page outside area</a>

View File

@ -0,0 +1 @@
Partial in /Areas/Accounts/Views/Shared/

View File

@ -0,0 +1 @@
@addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers"

View File

@ -0,0 +1 @@
@page "{sort?}/{top?}"

View File

@ -1 +1 @@
Hello from Pages/Shared
Hello from /Pages/Shared/

View File

@ -14,7 +14,7 @@ namespace RazorPagesWebSite
.UseKestrel()
.UseContentRoot(Directory.GetCurrentDirectory())
.UseIISIntegration()
.UseStartup<Startup>()
.UseStartup<StartupWithBasePath>()
.Build();
host.Run();

View File

@ -17,6 +17,7 @@ namespace RazorPagesWebSite
.AddCookieTempDataProvider()
.AddRazorPagesOptions(options =>
{
options.EnableAreas = true;
options.Conventions.AuthorizePage("/Conventions/Auth");
options.Conventions.AuthorizeFolder("/Conventions/AuthFolder");
});
@ -28,7 +29,10 @@ namespace RazorPagesWebSite
app.UseStaticFiles();
app.UseMvc();
app.UseMvc(routes =>
{
routes.MapRoute("areaRoute", "{area:exists}/{controller=Home}/{action=Index}");
});
}
}
}

View File

@ -0,0 +1,2 @@
Layout in /Views/Shared
@RenderBody()

View File

@ -1,6 +1,6 @@
@using Microsoft.AspNetCore.Mvc.RazorPages
@if (((PageActionDescriptor)ViewContext.ActionDescriptor).ViewEnginePath == "/WithViewStart/ViewStartAtRoot")
@if (ViewContext.ActionDescriptor is PageActionDescriptor actionDescriptor && actionDescriptor.ViewEnginePath == "/WithViewStart/ViewStartAtRoot")
{
<text>Hello from _ViewStart at root
</text>
}
}