From dfa085afaf4ddb0b1063eed35e070d6189acaca2 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Tue, 12 Dec 2017 15:01:40 -0800 Subject: [PATCH] Add support for areas to Razor Pages Fixes #6926 --- .../ApplicationModels/ActionModel.cs | 4 +- .../RazorViewEngine.cs | 5 + .../RazorViewEngineOptions.cs | 37 ++++- .../ApplicationModels/PageRouteModel.cs | 37 +++-- .../PageActionDescriptorProvider.cs | 23 +++- .../CompiledPageRouteModelProvider.cs | 79 ++++++++--- .../PageActionDescriptorChangeProvider.cs | 61 ++++++-- .../Internal/PageLoggerExtensions.cs | 14 ++ .../Internal/PageSelectorModel.cs | 74 ++++++++-- .../RazorPagesRazorViewEngineOptionsSetup.cs | 36 ++++- .../RazorProjectPageRouteModelProvider.cs | 81 +++++++++-- .../PageActionDescriptor.cs | 4 +- .../RazorPagesOptions.cs | 38 ++++- .../Routing/UrlHelperTest.cs | 44 ++++++ .../RazorPagesWithBasePathTest.cs | 84 ++++++++++- .../PageViewLocationExpanderTest.cs | 60 +++++++- .../CompiledPageRouteModelProviderTest.cs | 130 +++++++++++++++++- .../PageActionDescriptorChangeProviderTest.cs | 106 +++++++++++++- .../Internal/PageSelectorModelTest.cs | 89 ++++++++++++ ...zorPagesRazorViewEngineOptionsSetupTest.cs | 54 ++++++++ .../RazorProjectPageRouteModelProviderTest.cs | 126 +++++++++++++++++ .../AnchorTagHelperTest.cs | 62 +++++++++ .../FormActionTagHelperTest.cs | 66 ++++++++- .../FormTagHelperTest.cs | 58 ++++++++ .../TestFileChangeToken.cs | 9 ++ .../TestFileProvider.cs | 7 +- .../Accounts/Controllers/HomeController.cs | 14 ++ .../Areas/Accounts/Pages/About.cshtml | 2 + .../Pages/Manage/RenderPartials.cshtml | 9 ++ .../Pages/Manage/_PartialInManage.cshtml | 1 + .../Areas/Accounts/Pages/PageWithLinks.cshtml | 6 + .../Pages/PageWithRouteTemplate.cshtml | 7 + .../Accounts/Pages/RelativeLinks/Index.cshtml | 4 + .../Pages/_PartialInAreaPagesRoot.cshtml | 1 + .../Areas/Accounts/Views/Home/Index.cshtml | 3 + .../Shared/_PartialInAreasSharedViews.cshtml | 1 + .../Areas/Accounts/_ViewImports.cshtml | 1 + .../Areas/Products/Pages/List.cshtml | 1 + .../Pages/Shared/_FileInShared.cshtml | 2 +- test/WebSites/RazorPagesWebSite/Program.cs | 2 +- .../RazorPagesWebSite/StartupWithBasePath.cs | 6 +- .../Views/Shared/_GlobalLayout.cshtml | 2 + .../RazorPagesWebSite/_ViewStart.cshtml | 4 +- 43 files changed, 1353 insertions(+), 101 deletions(-) create mode 100644 test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageSelectorModelTest.cs create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/Controllers/HomeController.cs create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/About.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/Manage/RenderPartials.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/Manage/_PartialInManage.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/PageWithLinks.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/PageWithRouteTemplate.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/RelativeLinks/Index.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/_PartialInAreaPagesRoot.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/Views/Home/Index.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/Views/Shared/_PartialInAreasSharedViews.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Accounts/_ViewImports.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Areas/Products/Pages/List.cshtml create mode 100644 test/WebSites/RazorPagesWebSite/Views/Shared/_GlobalLayout.cshtml 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 +}