diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ActionModel.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ActionModel.cs
index 9fd79cce39..eaea73d612 100644
--- a/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ActionModel.cs
+++ b/src/Microsoft.AspNetCore.Mvc.Core/ApplicationModels/ActionModel.cs
@@ -98,8 +98,8 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
/// The value of is considered an implicit route value corresponding
/// to the key action and the value of is
/// considered an implicit route value corresponding to the key controller . These entries
- /// will be added to , but will not be visible in
- /// .
+ /// will be implicitly added to when the action
+ /// descriptor is created, but will not be visible in .
///
///
/// Entries in can override entries in
diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs
index d51099471d..ec594946e2 100644
--- a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs
+++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs
@@ -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;
diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs
index 31813ee0ea..532c924d32 100644
--- a/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs
+++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs
@@ -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
///
///
public IList PageViewLocationFormats { get; } = new List();
+ ///
+ /// Gets the locations where will search for views (such as layouts and partials)
+ /// when searched from the context of rendering a Razor Page within an area.
+ ///
+ ///
+ ///
+ /// Locations are format strings (see https://msdn.microsoft.com/en-us/library/txafckwd.aspx) which may contain
+ /// the following format items:
+ ///
+ ///
+ /// -
+ ///
{0} - View Name
+ ///
+ /// -
+ ///
{1} - Page Name
+ ///
+ /// -
+ ///
{2} - Area Name
+ ///
+ ///
+ ///
+ /// 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
+ ///
+ ///
+ public IList AreaPageViewLocationFormats { get; } = new List();
+
///
/// Gets the instances that should be included in Razor compilation, along with
/// those discovered by s.
diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs
index 8cc6c413af..277fdad905 100644
--- a/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs
+++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs
@@ -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
/// The path relative to the base path for page discovery.
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();
Selectors = new List();
+ RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
}
///
@@ -52,6 +46,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
Properties = new Dictionary(other.Properties);
Selectors = new List(other.Selectors.Select(m => new SelectorModel(m)));
+ RouteValues = new Dictionary(other.RouteValues, StringComparer.OrdinalIgnoreCase);
}
///
@@ -62,6 +57,9 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
///
/// Gets the path relative to the base path for page discovery.
///
+ ///
+ /// For area pages, this path is calculated relative to the of the specific area.
+ ///
public string ViewEnginePath { get; }
///
@@ -73,5 +71,18 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
/// Gets the instances.
///
public IList Selectors { get; }
+
+ ///
+ /// Gets a collection of route values that must be present in the
+ /// for the corresponding page to be selected.
+ ///
+ ///
+ ///
+ /// The value of is considered an implicit route value corresponding
+ /// to the key page . These entries will be implicitly added to
+ /// when the action descriptor is created, but will not be visible in .
+ ///
+ ///
+ public IDictionary RouteValues { get; }
}
-}
\ No newline at end of file
+}
diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs
index 5c02a67644..670267ba9c 100644
--- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs
+++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs
@@ -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(),
Properties = new Dictionary(model.Properties),
RelativePath = model.RelativePath,
- RouteValues = new Dictionary(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);
}
}
}
diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageRouteModelProvider.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageRouteModelProvider.cs
index e8110ecf9f..509842db97 100644
--- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageRouteModelProvider.cs
+++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/CompiledPageRouteModelProvider.cs
@@ -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 _logger;
private List _cachedModels;
public CompiledPageRouteModelProvider(
ApplicationPartManager applicationManager,
- IOptions pagesOptionsAccessor)
+ IOptions pagesOptionsAccessor,
+ ILoggerFactory loggerFactory)
{
_applicationManager = applicationManager;
_pagesOptions = pagesOptionsAccessor.Value;
+ _logger = loggerFactory.CreateLogger();
}
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();
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;
+ }
+
///
/// Gets the sequence of from .
///
@@ -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);
}
}
}
diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs
index 949cf71395..0e9f653b11 100644
--- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.cs
+++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageActionDescriptorChangeProvider.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 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);
}
}
diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageLoggerExtensions.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageLoggerExtensions.cs
index 7a88fcd71d..5e3f51113d 100644
--- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageLoggerExtensions.cs
+++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageLoggerExtensions.cs
@@ -19,6 +19,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
private static readonly Action _handlerMethodExecuted;
private static readonly Action _pageFilterShortCircuit;
private static readonly Action _malformedPageDirective;
+ private static readonly Action _unsupportedAreaPath;
private static readonly Action _notMostEffectiveFilter;
private static readonly Action _beforeExecutingMethodOnFilter;
private static readonly Action _afterExecutingMethodOnFilter;
@@ -61,6 +62,11 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
LogLevel.Trace,
2,
"{FilterType}: After executing {Method} on filter {Filter}.");
+
+ _unsupportedAreaPath = LoggerMessage.Define(
+ 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);
+ }
+ }
}
}
diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageSelectorModel.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageSelectorModel.cs
index f8db3320ad..70c85bb035 100644
--- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageSelectorModel.cs
+++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageSelectorModel.cs
@@ -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
diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorPagesRazorViewEngineOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorPagesRazorViewEngineOptionsSetup.cs
index fd73faba03..5e959574ed 100644
--- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorPagesRazorViewEngineOptionsSetup.cs
+++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorPagesRazorViewEngineOptionsSetup.cs
@@ -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;
}
}
}
diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorProjectPageRouteModelProvider.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorProjectPageRouteModelProvider.cs
index 1d5e33c93c..3be91f26d2 100644
--- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorProjectPageRouteModelProvider.cs
+++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/RazorProjectPageRouteModelProvider.cs
@@ -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);
+ }
}
}
diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs
index cfc631dae1..456bd613fd 100644
--- a/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs
+++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs
@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.Abstractions;
namespace Microsoft.AspNetCore.Mvc.RazorPages
{
- [DebuggerDisplay("{" + nameof(ViewEnginePath) + "}")]
+ [DebuggerDisplay(nameof(DebuggerDisplayString))]
public class PageActionDescriptor : ActionDescriptor
{
///
@@ -60,5 +60,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages
base.DisplayName = value;
}
}
+
+ private string DebuggerDisplayString() => $"{{ViewEnginePath = {nameof(ViewEnginePath)}, RelativePath = {nameof(RelativePath)}}}";
}
}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptions.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptions.cs
index 8bb833d4fc..bb013f5246 100644
--- a/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptions.cs
+++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/RazorPagesOptions.cs
@@ -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";
///
/// Gets a collection of instances that are applied during
@@ -42,5 +42,41 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages
_root = value;
}
}
+
+ ///
+ /// Gets or sets a value that determines if areas are enabled for Razor Pages.
+ /// Defaults to true .
+ ///
+ /// When enabled, any Razor Page under the directory structure /{AreaRootDirectory}/AreaName/{RootDirectory}/
+ /// will be associated with an area with the name AreaName .
+ ///
+ ///
+ ///
+ ///
+ public bool EnableAreas { get; set; }
+
+ ///
+ /// Application relative path used as the root of discovery for Razor Page files associated with areas.
+ /// Defaults to the /Areas directory under application root.
+ ///
+ ///
+ 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;
+ }
+ }
}
}
\ No newline at end of file
diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs
index 2dd726042c..b2754f56d3 100644
--- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Routing/UrlHelperTest.cs
@@ -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()))
+ .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(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 CreateMockUrlHelper(ActionContext context = null)
{
if (context == null)
diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs
index 8af3c4c609..42605e4d50 100644
--- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs
@@ -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 =
+@"Link inside area
+Link to external area
+Link to area action
+Link to non-area page ";
+
+ // Act
+ var response = await Client.GetStringAsync("/Accounts/PageWithLinks");
+
+ // Assert
+ Assert.Equal(expected, response.Trim());
+ }
+
+ [Fact]
+ public async Task PagesInAreas_CanGenerateRelativeLinks()
+ {
+ // Arrange
+ var expected =
+@"Parent directory
+Sibling directory
+Go back to root of different area ";
+
+ // 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());
+ }
}
}
diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageViewLocationExpanderTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageViewLocationExpanderTest.cs
index a0ad23fdbc..7623e4af9e 100644
--- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageViewLocationExpanderTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageViewLocationExpanderTest.cs
@@ -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(),
-
};
}
}
diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs
index 66576f20ed..3b4e4c7928 100644
--- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/CompiledPageRouteModelProviderTest.cs
@@ -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 _descriptors;
public TestCompiledPageRouteModelProvider(IEnumerable descriptors, RazorPagesOptions options)
- : base(new ApplicationPartManager(), Options.Create(options))
+ : base(new ApplicationPartManager(), Options.Create(options), NullLoggerFactory.Instance)
{
_descriptors = descriptors;
}
diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs
index 6399ffb5e0..21d7e6fa09 100644
--- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageActionDescriptorChangeProviderTest.cs
@@ -19,6 +19,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
{
// Arrange
var fileProvider = new Mock();
+ fileProvider.Setup(f => f.Watch(It.IsAny()))
+ .Returns(Mock.Of());
var accessor = Mock.Of(a => a.FileProvider == fileProvider.Object);
var templateEngine = new RazorTemplateEngine(
@@ -41,6 +43,8 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
{
// Arrange
var fileProvider = new Mock();
+ fileProvider.Setup(f => f.Watch(It.IsAny()))
+ .Returns(Mock.Of());
var accessor = Mock.Of(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();
+ fileProvider.Setup(f => f.Watch(It.IsAny()))
+ .Returns(Mock.Of());
+ var accessor = Mock.Of(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();
+ fileProvider.Setup(f => f.Watch(It.IsAny()))
+ .Returns(Mock.Of());
+ var accessor = Mock.Of(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(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(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(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(changeProvider.GetChangeToken());
+ Assert.Collection(compositeChangeToken.ChangeTokens,
+ changeToken => Assert.Same(fileProvider.GetChangeToken("/_ViewImports.cshtml"), changeToken),
+ changeToken => Assert.Same(fileProvider.GetChangeToken("/Pages/**/*.cshtml"), changeToken));
+ }
}
}
diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageSelectorModelTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageSelectorModelTest.cs
new file mode 100644
index 0000000000..ac43dac65c
--- /dev/null
+++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageSelectorModelTest.cs
@@ -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);
+ }
+ }
+}
diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs
index db819eb162..738716388f 100644
--- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorPagesRazorViewEngineOptionsSetupTest.cs
@@ -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()
{
diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorProjectPageRouteModelProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorProjectPageRouteModelProviderTest.cs
index 948b8271dc..ff262a8d71 100644
--- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorProjectPageRouteModelProviderTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/RazorProjectPageRouteModelProviderTest.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.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));
});
}
diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs
index b1af3675e0..c3cdb498f2 100644
--- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/AnchorTagHelperTest.cs
@@ -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()),
+ items: new Dictionary(),
+ uniqueId: "test");
+ var output = new TagHelperOutput(
+ "a",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) =>
+ {
+ var tagHelperContent = new DefaultTagHelperContent();
+ tagHelperContent.SetContent("Something");
+ return Task.FromResult(tagHelperContent);
+ });
+ output.Content.SetContent(string.Empty);
+
+ var generator = new Mock();
+ generator
+ .Setup(mock => mock.GeneratePageLink(
+ It.IsAny(),
+ string.Empty,
+ "/User/Home/Index",
+ "page-handler",
+ "http",
+ "contoso.com",
+ "hello=world",
+ It.IsAny(),
+ null))
+ .Callback((ViewContext v, string linkText, string pageName, string pageHandler, string protocol, string hostname, string fragment, object routeValues, object htmlAttributes) =>
+ {
+ var rvd = Assert.IsType(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")]
diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormActionTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormActionTagHelperTest.cs
index ccc19370f8..21773cae67 100644
--- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormActionTagHelperTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormActionTagHelperTest.cs
@@ -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(),
+ uniqueId: "test");
+ var output = new TagHelperOutput(
+ "submit",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) =>
+ {
+ return Task.FromResult(new DefaultTagHelperContent());
+ });
+
+ var urlHelper = new Mock();
+ urlHelper
+ .Setup(mock => mock.RouteUrl(It.IsAny()))
+ .Callback(routeContext =>
+ {
+ var rvd = Assert.IsType(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(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")]
diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs
index 88ca0ebc5a..11fbbe5d0a 100644
--- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/FormTagHelperTest.cs
@@ -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()),
+ items: new Dictionary(),
+ uniqueId: "test");
+ var output = new TagHelperOutput(
+ "form",
+ attributes: new TagHelperAttributeList(),
+ getChildContentAsync: (useCachedResult, encoder) =>
+ {
+ var tagHelperContent = new DefaultTagHelperContent();
+ tagHelperContent.SetContent("Something");
+ return Task.FromResult(tagHelperContent);
+ });
+ var generator = new Mock(MockBehavior.Strict);
+ generator
+ .Setup(mock => mock.GeneratePageForm(
+ viewContext,
+ "/Home/Admin/Post",
+ "page-handler",
+ It.IsAny(),
+ "hello-world",
+ null,
+ null))
+ .Callback((ViewContext _, string pageName, string pageHandler, object routeValues, string fragment, string method, object htmlAttributes) =>
+ {
+ var rvd = Assert.IsType(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, " ")]
[InlineData(false, "")]
diff --git a/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileChangeToken.cs b/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileChangeToken.cs
index a06c378185..5d814eb353 100644
--- a/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileChangeToken.cs
+++ b/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileChangeToken.cs
@@ -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 callback, object state)
{
return new NullDisposable();
@@ -22,5 +29,7 @@ namespace Microsoft.Extensions.Primitives
{
}
}
+
+ public override string ToString() => Filter;
}
}
\ No newline at end of file
diff --git a/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileProvider.cs b/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileProvider.cs
index fb612c6a46..e7d32519dc 100644
--- a/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileProvider.cs
+++ b/test/Microsoft.AspNetCore.Mvc.TestCommon/TestFileProvider.cs
@@ -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;
}
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Controllers/HomeController.cs b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Controllers/HomeController.cs
new file mode 100644
index 0000000000..596befbd2c
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Controllers/HomeController.cs
@@ -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();
+ }
+
+}
\ No newline at end of file
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/About.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/About.cshtml
new file mode 100644
index 0000000000..41bcf05b63
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/About.cshtml
@@ -0,0 +1,2 @@
+@page
+Hello from a page in Accounts area
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/Manage/RenderPartials.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/Manage/RenderPartials.cshtml
new file mode 100644
index 0000000000..f46c0eaac6
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/Manage/RenderPartials.cshtml
@@ -0,0 +1,9 @@
+@page
+@{
+ Layout = "_GlobalLayout";
+}
+
+
+
+
+
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/Manage/_PartialInManage.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/Manage/_PartialInManage.cshtml
new file mode 100644
index 0000000000..8c48fa2a89
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/Manage/_PartialInManage.cshtml
@@ -0,0 +1 @@
+Partial in /Areas/Accounts/Pages/Manage/
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/PageWithLinks.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/PageWithLinks.cshtml
new file mode 100644
index 0000000000..724d540236
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/PageWithLinks.cshtml
@@ -0,0 +1,6 @@
+@page
+Link inside area
+Link to external area
+Link to area action
+Link to non-area page
+
\ No newline at end of file
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/PageWithRouteTemplate.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/PageWithRouteTemplate.cshtml
new file mode 100644
index 0000000000..67da977644
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/PageWithRouteTemplate.cshtml
@@ -0,0 +1,7 @@
+@page "{id}"
+@functions
+{
+ [BindProperty(SupportsGet = true)]
+ public string Id { get; set; }
+}
+The id is @Id
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/RelativeLinks/Index.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/RelativeLinks/Index.cshtml
new file mode 100644
index 0000000000..07c7ea0ec1
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/RelativeLinks/Index.cshtml
@@ -0,0 +1,4 @@
+@page
+Parent directory
+Sibling directory
+Go back to root of different area
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/_PartialInAreaPagesRoot.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/_PartialInAreaPagesRoot.cshtml
new file mode 100644
index 0000000000..763bcc1103
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/_PartialInAreaPagesRoot.cshtml
@@ -0,0 +1 @@
+Partial in /Areas/Accounts/Pages/
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Views/Home/Index.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Views/Home/Index.cshtml
new file mode 100644
index 0000000000..c59d1afadc
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Views/Home/Index.cshtml
@@ -0,0 +1,3 @@
+Link to page inside current area
+Link to page in different area
+Link to page outside area
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Views/Shared/_PartialInAreasSharedViews.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Views/Shared/_PartialInAreasSharedViews.cshtml
new file mode 100644
index 0000000000..544da589c2
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Views/Shared/_PartialInAreasSharedViews.cshtml
@@ -0,0 +1 @@
+Partial in /Areas/Accounts/Views/Shared/
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/_ViewImports.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/_ViewImports.cshtml
new file mode 100644
index 0000000000..aaf882de29
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/_ViewImports.cshtml
@@ -0,0 +1 @@
+@addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers"
diff --git a/test/WebSites/RazorPagesWebSite/Areas/Products/Pages/List.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Products/Pages/List.cshtml
new file mode 100644
index 0000000000..a860f502be
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Areas/Products/Pages/List.cshtml
@@ -0,0 +1 @@
+@page "{sort?}/{top?}"
\ No newline at end of file
diff --git a/test/WebSites/RazorPagesWebSite/Pages/Shared/_FileInShared.cshtml b/test/WebSites/RazorPagesWebSite/Pages/Shared/_FileInShared.cshtml
index cbc41653cb..72df466583 100644
--- a/test/WebSites/RazorPagesWebSite/Pages/Shared/_FileInShared.cshtml
+++ b/test/WebSites/RazorPagesWebSite/Pages/Shared/_FileInShared.cshtml
@@ -1 +1 @@
-Hello from Pages/Shared
\ No newline at end of file
+Hello from /Pages/Shared/
diff --git a/test/WebSites/RazorPagesWebSite/Program.cs b/test/WebSites/RazorPagesWebSite/Program.cs
index 1f48674aba..2c88d76f44 100644
--- a/test/WebSites/RazorPagesWebSite/Program.cs
+++ b/test/WebSites/RazorPagesWebSite/Program.cs
@@ -14,7 +14,7 @@ namespace RazorPagesWebSite
.UseKestrel()
.UseContentRoot(Directory.GetCurrentDirectory())
.UseIISIntegration()
- .UseStartup()
+ .UseStartup()
.Build();
host.Run();
diff --git a/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs b/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs
index a2e31679d3..9bf43aff50 100644
--- a/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs
+++ b/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs
@@ -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}");
+ });
}
}
}
diff --git a/test/WebSites/RazorPagesWebSite/Views/Shared/_GlobalLayout.cshtml b/test/WebSites/RazorPagesWebSite/Views/Shared/_GlobalLayout.cshtml
new file mode 100644
index 0000000000..3f9d21e30c
--- /dev/null
+++ b/test/WebSites/RazorPagesWebSite/Views/Shared/_GlobalLayout.cshtml
@@ -0,0 +1,2 @@
+Layout in /Views/Shared
+@RenderBody()
diff --git a/test/WebSites/RazorPagesWebSite/_ViewStart.cshtml b/test/WebSites/RazorPagesWebSite/_ViewStart.cshtml
index 99606637f8..29ff986af7 100644
--- a/test/WebSites/RazorPagesWebSite/_ViewStart.cshtml
+++ b/test/WebSites/RazorPagesWebSite/_ViewStart.cshtml
@@ -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")
{
Hello from _ViewStart at root
-}
\ No newline at end of file
+}