Skip to content
This repository has been archived by the owner on Dec 14, 2018. It is now read-only.

Commit

Permalink
Implement view search for pages
Browse files Browse the repository at this point in the history
The View Engine now needs to know about pages :(. This isn't ideal but the
view engine needs to know what set of search paths to use. This was
already hardcoded for controllers vs controllers + areas. It felt right to
further hardcode instead of introduce a wierd abstraction that we only
use.

Additionally pages use a view location expander to implement an ascending
directory search.
  • Loading branch information
rynowak committed Apr 18, 2017
1 parent c56b64f commit a8eb5be
Show file tree
Hide file tree
Showing 17 changed files with 547 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public ViewLocationCacheKey(
viewName,
controllerName: null,
areaName: null,
pageName: null,
isMainPage: isMainPage,
values: null)
{
Expand All @@ -35,18 +36,21 @@ public ViewLocationCacheKey(
/// <param name="viewName">The view name.</param>
/// <param name="controllerName">The controller name.</param>
/// <param name="areaName">The area name.</param>
/// <param name="pageName">The page name.</param>
/// <param name="isMainPage">Determines if the page being found is the main page for an action.</param>
/// <param name="values">Values from <see cref="IViewLocationExpander"/> instances.</param>
public ViewLocationCacheKey(
string viewName,
string controllerName,
string areaName,
string pageName,
bool isMainPage,
IReadOnlyDictionary<string, string> values)
{
ViewName = viewName;
ControllerName = controllerName;
AreaName = areaName;
PageName = pageName;
IsMainPage = isMainPage;
ViewLocationExpanderValues = values;
}
Expand All @@ -66,6 +70,11 @@ public ViewLocationCacheKey(
/// </summary>
public string AreaName { get; }

/// <summary>
/// Gets the page name.
/// </summary>
public string PageName { get; }

/// <summary>
/// Determines if the page being found is the main page for an action.
/// </summary>
Expand All @@ -82,7 +91,8 @@ public bool Equals(ViewLocationCacheKey y)
if (IsMainPage != y.IsMainPage ||
!string.Equals(ViewName, y.ViewName, StringComparison.Ordinal) ||
!string.Equals(ControllerName, y.ControllerName, StringComparison.Ordinal) ||
!string.Equals(AreaName, y.AreaName, StringComparison.Ordinal))
!string.Equals(AreaName, y.AreaName, StringComparison.Ordinal) ||
!string.Equals(PageName, y.PageName, StringComparison.Ordinal))
{
return false;
}
Expand Down Expand Up @@ -131,6 +141,7 @@ public override int GetHashCode()
hashCodeCombiner.Add(ViewName, StringComparer.Ordinal);
hashCodeCombiner.Add(ControllerName, StringComparer.Ordinal);
hashCodeCombiner.Add(AreaName, StringComparer.Ordinal);
hashCodeCombiner.Add(PageName, StringComparer.Ordinal);

if (ViewLocationExpanderValues != null)
{
Expand Down
37 changes: 31 additions & 6 deletions src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.AspNetCore.Mvc.Razor.Internal;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Razor.Language;
Expand All @@ -33,8 +32,10 @@ public class RazorViewEngine : IRazorViewEngine
public static readonly string ViewExtension = ".cshtml";
private const string ViewStartFileName = "_ViewStart.cshtml";

private const string ControllerKey = "controller";
private const string AreaKey = "area";
private const string ControllerKey = "controller";
private const string PageKey = "page";

private const string ParentDirectoryToken = "..";
private static readonly TimeSpan _cacheExpirationDuration = TimeSpan.FromMinutes(20);
private static readonly char[] _pathSeparators = new[] { '/', '\\' };
Expand Down Expand Up @@ -272,11 +273,13 @@ private ViewLocationCacheResult LocatePageFromViewLocations(
{
var controllerName = GetNormalizedRouteValue(actionContext, ControllerKey);
var areaName = GetNormalizedRouteValue(actionContext, AreaKey);
var razorPageName = GetNormalizedRouteValue(actionContext, PageKey);
var expanderContext = new ViewLocationExpanderContext(
actionContext,
pageName,
controllerName,
areaName,
razorPageName,
isMainPage);
Dictionary<string, string> expanderValues = null;

Expand All @@ -296,6 +299,7 @@ private ViewLocationCacheResult LocatePageFromViewLocations(
expanderContext.ViewName,
expanderContext.ControllerName,
expanderContext.AreaName,
expanderContext.PageName,
expanderContext.IsMainPage,
expanderValues);

Expand Down Expand Up @@ -396,14 +400,35 @@ public string GetAbsolutePath(string executingFilePath, string pagePath)
return builder.ToString();
}

// internal for tests
internal IEnumerable<string> GetViewLocationFormats(ViewLocationExpanderContext context)
{
if (!string.IsNullOrEmpty(context.AreaName) &&
!string.IsNullOrEmpty(context.ControllerName))
{
return _options.AreaViewLocationFormats;
}
else if (!string.IsNullOrEmpty(context.ControllerName))
{
return _options.ViewLocationFormats;
}
else if (!string.IsNullOrEmpty(context.PageName))
{
return _options.PageViewLocationFormats;
}
else
{
// If we don't match one of these conditions, we'll just treat it like regular controller/action
// and use those search paths. This is what we did in 1.0.0 without giving much thought to it.
return _options.ViewLocationFormats;
}
}

private ViewLocationCacheResult OnCacheMiss(
ViewLocationExpanderContext expanderContext,
ViewLocationCacheKey cacheKey)
{
// Only use the area view location formats if we have an area token.
IEnumerable<string> viewLocations = !string.IsNullOrEmpty(expanderContext.AreaName) ?
_options.AreaViewLocationFormats :
_options.ViewLocationFormats;
var viewLocations = GetViewLocationFormats(expanderContext);

for (var i = 0; i < _options.ViewLocationExpanders.Count; i++)
{
Expand Down
2 changes: 2 additions & 0 deletions src/Microsoft.AspNetCore.Mvc.Razor/RazorViewEngineOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ public class RazorViewEngineOptions
/// </remarks>
public IList<string> AreaViewLocationFormats { get; } = new List<string>();

public IList<string> PageViewLocationFormats { get; } = new List<string>();

/// <summary>
/// Gets the <see cref="MetadataReference" /> instances that should be included in Razor compilation, along with
/// those discovered by <see cref="MetadataReferenceFeatureProvider" />s.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ public class ViewLocationExpanderContext
/// <param name="viewName">The view name.</param>
/// <param name="controllerName">The controller name.</param>
/// <param name="areaName">The area name.</param>
/// <param name="pageName">The page name.</param>
/// <param name="isMainPage">Determines if the page being found is the main page for an action.</param>
public ViewLocationExpanderContext(
ActionContext actionContext,
string viewName,
string controllerName,
string areaName,
string pageName,
bool isMainPage)
{
if (actionContext == null)
Expand All @@ -40,9 +42,10 @@ public ViewLocationExpanderContext(
ViewName = viewName;
ControllerName = controllerName;
AreaName = areaName;
PageName = pageName;
IsMainPage = isMainPage;
}

/// <summary>
/// Gets the <see cref="Mvc.ActionContext"/> for the current executing action.
/// </summary>
Expand All @@ -58,6 +61,12 @@ public ViewLocationExpanderContext(
/// </summary>
public string ControllerName { get; }

/// <summary>
/// Gets the page name. This will be the value of the <c>page</c> route value when rendering a Page from the
/// Razor Pages framework. This value will be <c>null</c> if rendering a view as the result of a controller.
/// </summary>
public string PageName { get; }

/// <summary>
/// Gets the area name.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure;
using Microsoft.AspNetCore.Mvc.RazorPages.Internal;
Expand Down Expand Up @@ -71,6 +72,8 @@ internal static void AddServices(IServiceCollection services)
// Options
services.TryAddEnumerable(
ServiceDescriptor.Transient<IConfigureOptions<RazorPagesOptions>, RazorPagesOptionsSetup>());
services.TryAddEnumerable(
ServiceDescriptor.Transient<IConfigureOptions<RazorViewEngineOptions>, RazorPagesRazorViewEngineOptionsSetup>());

// Action description and invocation
services.TryAddEnumerable(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc.Razor;

namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
{
public class PageViewLocationExpander : IViewLocationExpander
{
public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
{
if (string.IsNullOrEmpty(context.PageName))
{
// Not a page - just act natural.
return viewLocations;
}

return ExpandPageHierarchy();

IEnumerable<string> ExpandPageHierarchy()
{
foreach (var location in viewLocations)
{
// For pages, we only handle the 'page' token when it's surrounded by slashes.
//
// Explanation:
// We need the ability to 'collapse' the segment which requires us to understand slashes.
// Imagine a path like /{1}/{0} - we might end up with //{0} if we don't do *something* with
// the slashes. Instead of picking on (leading or trailing), we choose both. This seems
// less arbitrary.
//
//
// So given a Page like /Account/Manage/Index using /Pages as the root, and the default set of
// search paths, this will produce the expanded paths:
//
// /Pages/Account/Manage/{0}.cshtml
// /Pages/Account/{0}.cshtml
// /Pages/{0}.cshtml
// /Views/Shared/{0}.cshtml

if (!location.Contains("/{1}/"))
{
// If the location doesn't have the 'page' replacement token just return it as-is.
yield return location;
continue;
}

// For locations with the 'page' token - expand them into an ascending directory search,
// but only up to the pages root.
//
// This is easy because the 'page' token already trims the root directory.
var end = context.PageName.Length;

while (end > 0 && (end = context.PageName.LastIndexOf('/', end - 1)) != -1)
{
// PageName always starts with `/`
yield return location.Replace("/{1}/", context.PageName.Substring(0, end + 1));
}
}
}
}

public void PopulateValues(ViewLocationExpanderContext context)
{
// The value we care about - 'page' is already part of the system. We don't need to add it manually.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Diagnostics;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure;
using Microsoft.Extensions.Options;

namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal
{
public class RazorPagesRazorViewEngineOptionsSetup : IConfigureOptions<RazorViewEngineOptions>
{
private readonly IOptions<RazorPagesOptions> _pagesOptions;

public RazorPagesRazorViewEngineOptionsSetup(IOptions<RazorPagesOptions> pagesOptions)
{
_pagesOptions = pagesOptions;
}

public void Configure(RazorViewEngineOptions options)
{
Debug.Assert(_pagesOptions.Value.RootDirectory.Length > 0);

if (_pagesOptions.Value.RootDirectory == "/")
{
options.PageViewLocationFormats.Add("/{1}/{0}" + RazorViewEngine.ViewExtension);
}
else
{
options.PageViewLocationFormats.Add(_pagesOptions.Value.RootDirectory + "/{1}/{0}" + RazorViewEngine.ViewExtension);
}

options.PageViewLocationFormats.Add("/Views/Shared/{0}" + RazorViewEngine.ViewExtension);

options.ViewLocationExpanders.Add(new PageViewLocationExpander());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;

namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
public class RazorPagesViewSearchTest : IClassFixture<MvcTestFixture<RazorPagesWebSite.Startup>>
{
public RazorPagesViewSearchTest(MvcTestFixture<RazorPagesWebSite.Startup> fixture)
{
Client = fixture.Client;
}

public HttpClient Client { get; }

[Fact]
public async Task Page_CanFindPartial_InCurrentDirectory()
{
// Arrange & Act
var content = await Client.GetStringAsync("http://localhost/Pages/ViewSearch?partial=_Sibling");

// Assert
Assert.Equal("Hello from sibling", content.Trim());
}

[Fact]
public async Task Page_CanFindPartial_InParentDirectory()
{
// Arrange & Act
var content = await Client.GetStringAsync("http://localhost/Pages/ViewSearch?partial=_Parent");

// Assert
Assert.Equal("Hello from parent", content.Trim());
}

[Fact]
public async Task Page_CanFindPartial_InRootDirectory()
{
// Arrange & Act
var content = await Client.GetStringAsync("http://localhost/Pages/ViewSearch?partial=_Root");

// Assert
Assert.Equal("Hello from root", content.Trim());
}

[Fact]
public async Task Page_CanFindPartial_InViewsSharedDirectory()
{
// Arrange & Act
var content = await Client.GetStringAsync("http://localhost/Pages/ViewSearch?partial=_Shared");

// Assert
Assert.Equal("Hello from shared", content.Trim());
}
}
}
Loading

0 comments on commit a8eb5be

Please sign in to comment.