diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/EfControllerScaffolderBuilderExtensions.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/EfControllerScaffolderBuilderExtensions.cs index d71586174..c0a719d1b 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/EfControllerScaffolderBuilderExtensions.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/EfControllerScaffolderBuilderExtensions.cs @@ -177,7 +177,21 @@ public static IScaffoldBuilder WithMvcViewsStep(this IScaffoldBuilder builder) //TODO add extensions if 'TemplateFoldersUtilities' is not reworked. var allT4TemplatePaths = new TemplateFoldersUtilities().GetAllT4TemplatesForTargetFramework(["Views"], viewModel.ProjectInfo.ProjectPath); - var viewTemplateProperties = ViewHelper.GetTextTemplatingProperties(allT4TemplatePaths, viewModel); + + // Use controller root name for view folder to match MVC conventions + // e.g. BlogsController -> /Views/Blogs/ + string? viewFolderName = null; + context.Properties.TryGetValue(nameof(EfControllerModel), out var efControllerModelObj); + if (efControllerModelObj is EfControllerModel efControllerModel) + { + var controllerName = efControllerModel.ControllerName; + const string controllerSuffix = "Controller"; + viewFolderName = controllerName.EndsWith(controllerSuffix, StringComparison.Ordinal) + ? controllerName.Substring(0, controllerName.Length - controllerSuffix.Length) + : controllerName; + } + + var viewTemplateProperties = ViewHelper.GetTextTemplatingProperties(allT4TemplatePaths, viewModel, viewFolderName); if (viewTemplateProperties.Any()) { step.TextTemplatingProperties = viewTemplateProperties; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs index 237575859..b6c4cf555 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/ViewHelper.cs @@ -40,8 +40,9 @@ internal class ViewHelper /// /// The collection of all T4 template paths. /// The view model containing project and model information. + /// An optional folder name for the view output path. If not provided, the model type name will be used. /// An enumerable collection of instances. - internal static IEnumerable GetTextTemplatingProperties(IEnumerable allT4TemplatePaths, ViewModel viewModel) + internal static IEnumerable GetTextTemplatingProperties(IEnumerable allT4TemplatePaths, ViewModel viewModel, string? viewFolderName = null) { var textTemplatingProperties = new List(); if (allT4TemplatePaths is not null && allT4TemplatePaths.Any()) @@ -57,7 +58,10 @@ internal static IEnumerable GetTextTemplatingProperties( continue; } - string baseOutputPath = GetBaseOutputPath(viewModel.ModelInfo.ModelTypeName, viewModel.ProjectInfo.ProjectPath); + string folderName = viewFolderName is not null && IsValidFolderName(viewFolderName) + ? viewFolderName + : viewModel.ModelInfo.ModelTypeName; + string baseOutputPath = GetBaseOutputPath(folderName, viewModel.ProjectInfo.ProjectPath); string outputFileName = Path.Combine(baseOutputPath, $"{templateName}{Common.Constants.ViewExtension}"); textTemplatingProperties.Add(new() { @@ -153,4 +157,12 @@ private static string GetBaseOutputPath(string modelName, string? projectPath) string projectBasePath = Path.GetDirectoryName(projectPath) ?? Directory.GetCurrentDirectory(); return Path.Combine(projectBasePath, "Views", modelName); } + + private static bool IsValidFolderName(string name) + { + if (string.IsNullOrWhiteSpace(name)) return false; + if (name == "." || name == "..") return false; + if (name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) return false; + return true; + } } diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/ViewHelperTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/ViewHelperTests.cs index 84885a61e..6d008c846 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/ViewHelperTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/ViewHelperTests.cs @@ -305,4 +305,85 @@ public void IndexTemplate_Constant_HasExpectedValue() // Assert Assert.Equal("Index.tt", ViewHelper.IndexTemplate); } + + [Fact] + public void GetTextTemplatingProperties_WithViewFolderName_UsesViewFolderNameInOutputPath() + { + // Arrange + string projectPath = Path.Combine("test", "project", "TestProject.csproj"); + string templatePath = Path.Combine("templates", ViewHelper.CreateTemplate); + List templatePaths = [templatePath]; + + ViewModel viewModel = new ViewModel + { + PageType = "Create", + ModelInfo = new ModelInfo { ModelTypeName = "Blog" }, + ProjectInfo = new ProjectInfo(projectPath), + DbContextInfo = new DbContextInfo() + }; + + // Act + IEnumerable result = ViewHelper.GetTextTemplatingProperties(templatePaths, viewModel, "Blogs"); + + // Assert + TextTemplatingProperty? property = result.FirstOrDefault(); + Assert.NotNull(property); + Assert.Contains("Blogs", property.OutputPath); + Assert.DoesNotContain("Blog" + Path.DirectorySeparatorChar, property.OutputPath.Replace("Blogs", string.Empty)); + } + + [Fact] + public void GetTextTemplatingProperties_WithNullViewFolderName_FallsBackToModelTypeName() + { + // Arrange + string projectPath = Path.Combine("test", "project", "TestProject.csproj"); + string templatePath = Path.Combine("templates", ViewHelper.CreateTemplate); + List templatePaths = [templatePath]; + + ViewModel viewModel = new ViewModel + { + PageType = "Create", + ModelInfo = new ModelInfo { ModelTypeName = "Product" }, + ProjectInfo = new ProjectInfo(projectPath), + DbContextInfo = new DbContextInfo() + }; + + // Act + IEnumerable result = ViewHelper.GetTextTemplatingProperties(templatePaths, viewModel, null); + + // Assert + TextTemplatingProperty? property = result.FirstOrDefault(); + Assert.NotNull(property); + Assert.Contains("Product", property.OutputPath); + } + + [Theory] + [InlineData("..")] + [InlineData(".")] + [InlineData("Views/../secrets")] + [InlineData("foo/bar")] + [InlineData("foo\\bar")] + public void GetTextTemplatingProperties_WithInvalidViewFolderName_FallsBackToModelTypeName(string invalidFolderName) + { + // Arrange + string projectPath = Path.Combine("test", "project", "TestProject.csproj"); + string templatePath = Path.Combine("templates", ViewHelper.CreateTemplate); + List templatePaths = [templatePath]; + + ViewModel viewModel = new ViewModel + { + PageType = "Create", + ModelInfo = new ModelInfo { ModelTypeName = "Product" }, + ProjectInfo = new ProjectInfo(projectPath), + DbContextInfo = new DbContextInfo() + }; + + // Act + IEnumerable result = ViewHelper.GetTextTemplatingProperties(templatePaths, viewModel, invalidFolderName); + + // Assert + TextTemplatingProperty? property = result.FirstOrDefault(); + Assert.NotNull(property); + Assert.Contains("Product", property.OutputPath); + } }