From 95b33018bad36c431cadf7c638851a4237a4903e Mon Sep 17 00:00:00 2001 From: Dean Marcussen Date: Sat, 11 Apr 2020 17:32:33 +0100 Subject: [PATCH] Autoroute container routing (#5665) --- .../Drivers/AutoroutePartDisplay.cs | 74 +++- .../Handlers/AutorouteContentHandler.cs | 39 +++ .../Handlers/AutoroutePartHandler.cs | 298 ++++++++++++++-- .../Handlers/DefaultRouteContentHandler.cs | 39 +++ .../Indexes/AutoroutePartIndex.cs | 119 ++++++- ...tentAutorouteLiquidTemplateEventHandler.cs | 5 +- .../OrchardCore.Autoroute/Migrations.cs | 45 ++- .../Models/AutoroutePart.cs | 16 + .../Models/AutoroutePartSettings.cs | 20 ++ .../Routing/AutoRouteTransformer.cs | 14 +- .../Routing/AutorouteValuesAddressScheme.cs | 15 +- .../Services/AutorouteAliasProvider.cs | 7 +- .../Services/AutorouteEntries.cs | 62 +++- .../AutoroutePartSettingsDisplayDriver.cs | 16 +- .../OrchardCore.Autoroute/Startup.cs | 8 +- .../AutoroutePartSettingsViewModel.cs | 4 + .../ViewModels/AutoroutePartViewModel.cs | 3 + .../Views/AutoroutePart.Edit.cshtml | 131 ++++--- .../Views/AutoroutePartSettings.Edit.cshtml | 52 ++- .../Services/CulturePickerService.cs | 10 +- .../Controllers/ItemController.cs | 5 +- .../OrchardCore.Contents/Startup.cs | 2 + .../Drivers/BagPartDisplay.cs | 1 - .../Handlers/BagPartHandler.cs | 26 ++ .../OrchardCore.Flows/Startup.cs | 4 +- .../Controllers/AdminController.cs | 8 + .../Controllers/TagController.cs | 29 +- .../Drivers/TaxonomyFieldDriverHelper.cs | 1 + .../Drivers/TaxonomyPartDisplayDriver.cs | 22 +- .../Drivers/TermPartContentDriver.cs | 170 +++++++++ .../Handlers/TaxonomyPartHandler.cs | 24 ++ .../Handlers/TermPartContentHandler.cs | 27 ++ .../Liquid/TaxonomyTermFilter.cs | 6 +- .../OrchardCore.Taxonomies/Migrations.cs | 44 ++- .../OrchardCore.Taxonomies/Models/TermPart.cs | 2 +- .../OrchardCore.Taxonomies.csproj | 1 + .../OrchardCore.Taxonomies/Startup.cs | 12 +- .../OrchardCore.Taxonomies/TermShapes.cs | 326 ++++++++++++++++++ .../ViewModels/TaxonomyPartViewModel.cs | 18 + .../ViewModels/TermPartViewModel.cs | 17 + .../Views/Content.TermAdmin.cshtml | 8 - .../Views/ContentPart.TermAdmin.cshtml | 15 - .../Views/TaxonomyPart.Empty.cshtml | 0 .../Views/TaxonomyPart.cshtml | 8 + .../Views/Term.Edit.cshtml | 41 --- .../OrchardCore.Taxonomies/Views/Term.cshtml | 31 +- .../Views/TermContentItem.cshtml | 3 + .../Views/TermItem.cshtml | 18 + .../Views/TermPart.cshtml | 25 ++ .../TheBlogTheme/Recipes/blog.recipe.json | 64 +++- .../Views/BlogPost-Category.liquid | 12 +- ...ost-Tags-TaxonomyField-Tags.Display.liquid | 15 +- .../Views/Category-TermPart.liquid | 13 + .../Views/Content-BlogPost.Summary.liquid | 34 +- .../Views/Content-Category.Summary.liquid | 8 + .../Views/Content-Category.liquid | 21 ++ .../TheBlogTheme/Views/Tag-TermPart.liquid | 13 + .../TheBlogTheme/Views/Term-Category.liquid | 6 + .../Views/TermContentItem-Tag.liquid | 7 + .../Views/TermItem-Category.liquid | 13 + .../TheBlogTheme/wwwroot/css/bootstrap-oc.css | 45 ++- .../wwwroot/css/bootstrap-oc.min.css | 2 +- .../wwwroot/scss/bootstrap-oc.scss | 2 + .../wwwroot/scss/modules/_taxonomy.scss | 18 + .../ContentManagerExtension.cs | 1 + .../IContentManager.cs | 33 ++ .../Routing/AutorouteEntriesExtensions.cs | 8 +- .../Routing/AutorouteEntry.cs | 31 +- .../Routing/AutorouteOptions.cs | 4 + .../Routing/ContainedContentItemsAspect.cs | 14 + .../Routing/IAutorouteEntries.cs | 4 +- .../Routing/RouteHandlerAspect.cs | 20 ++ .../reference/modules/Autoroute/README.md | 105 ++++++ .../reference/modules/Taxonomies/README.md | 192 +++++++++++ .../Routing/AutorouteEntriesTests.cs | 151 ++++++++ 75 files changed, 2401 insertions(+), 306 deletions(-) create mode 100644 src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/AutorouteContentHandler.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/DefaultRouteContentHandler.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Flows/Handlers/BagPartHandler.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TermPartContentDriver.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/Handlers/TaxonomyPartHandler.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/Handlers/TermPartContentHandler.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/TermShapes.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/ViewModels/TaxonomyPartViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/ViewModels/TermPartViewModel.cs delete mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/ContentPart.TermAdmin.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TaxonomyPart.Empty.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TaxonomyPart.cshtml delete mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Term.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermContentItem.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermItem.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermPart.cshtml create mode 100644 src/OrchardCore.Themes/TheBlogTheme/Views/Category-TermPart.liquid create mode 100644 src/OrchardCore.Themes/TheBlogTheme/Views/Content-Category.Summary.liquid create mode 100644 src/OrchardCore.Themes/TheBlogTheme/Views/Content-Category.liquid create mode 100644 src/OrchardCore.Themes/TheBlogTheme/Views/Tag-TermPart.liquid create mode 100644 src/OrchardCore.Themes/TheBlogTheme/Views/Term-Category.liquid create mode 100644 src/OrchardCore.Themes/TheBlogTheme/Views/TermContentItem-Tag.liquid create mode 100644 src/OrchardCore.Themes/TheBlogTheme/Views/TermItem-Category.liquid create mode 100644 src/OrchardCore.Themes/TheBlogTheme/wwwroot/scss/modules/_taxonomy.scss create mode 100644 src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/ContainedContentItemsAspect.cs create mode 100644 src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/RouteHandlerAspect.cs create mode 100644 test/OrchardCore.Tests/Routing/AutorouteEntriesTests.cs diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Drivers/AutoroutePartDisplay.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Drivers/AutoroutePartDisplay.cs index 44eca7d6169..111efc47191 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Drivers/AutoroutePartDisplay.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Drivers/AutoroutePartDisplay.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; @@ -53,16 +54,28 @@ public override IDisplayResult Edit(AutoroutePart autoroutePart, BuildPartEditor { model.Path = autoroutePart.Path; model.AutoroutePart = autoroutePart; + model.ContentItem = autoroutePart.ContentItem; model.SetHomepage = false; var siteSettings = await _siteService.GetSiteSettingsAsync(); var homeRoute = siteSettings.HomeRoute; - if (autoroutePart.ContentItem.ContentItemId == homeRoute?[_options.ContentItemIdKey]?.ToString()) + if (homeRoute != null && homeRoute.TryGetValue(_options.ContainedContentItemIdKey, out var containedContentItemId)) + { + if (string.Equals(autoroutePart.ContentItem.ContentItemId, containedContentItemId.ToString(), StringComparison.OrdinalIgnoreCase)) + { + model.IsHomepage = true; + } + } + else if (string.Equals(autoroutePart.ContentItem.ContentItemId, homeRoute?[_options.ContentItemIdKey]?.ToString(), StringComparison.OrdinalIgnoreCase)) { model.IsHomepage = true; } + model.Disabled = autoroutePart.Disabled; + model.Absolute = autoroutePart.Absolute; + model.RouteContainedItems = autoroutePart.RouteContainedItems; + model.Settings = context.TypePartDefinition.GetSettings(); }); } @@ -71,34 +84,42 @@ public override async Task UpdateAsync(AutoroutePart model, IUpd { var viewModel = new AutoroutePartViewModel(); - await updater.TryUpdateModelAsync(viewModel, Prefix, t => t.Path, t => t.UpdatePath); + await updater.TryUpdateModelAsync(viewModel, Prefix, t => t.Path, t => t.UpdatePath, t => t.RouteContainedItems, t => t.Absolute, t => t.Disabled); var settings = context.TypePartDefinition.GetSettings(); - if (settings.AllowCustomPath) - { - model.Path = viewModel.Path; - } + model.Disabled = viewModel.Disabled; + model.Absolute = viewModel.Absolute; + model.RouteContainedItems = viewModel.RouteContainedItems; - if (settings.AllowUpdatePath && viewModel.UpdatePath) + // When disabled these values are not updated. + if (!model.Disabled) { - // Make it empty to force a regeneration - model.Path = ""; - } + if (settings.AllowCustomPath) + { + model.Path = viewModel.Path; + } + + if (settings.AllowUpdatePath && viewModel.UpdatePath) + { + // Make it empty to force a regeneration + model.Path = ""; + } - var httpContext = _httpContextAccessor.HttpContext; + var httpContext = _httpContextAccessor.HttpContext; - if (httpContext != null && await _authorizationService.AuthorizeAsync(httpContext.User, Permissions.SetHomepage)) - { - await updater.TryUpdateModelAsync(model, Prefix, t => t.SetHomepage); - } + if (httpContext != null && await _authorizationService.AuthorizeAsync(httpContext.User, Permissions.SetHomepage)) + { + await updater.TryUpdateModelAsync(model, Prefix, t => t.SetHomepage); + } - await ValidateAsync(model, updater); + await ValidateAsync(model, updater, settings); + } return Edit(model, context); } - private async Task ValidateAsync(AutoroutePart autoroute, IUpdateModel updater) + private async Task ValidateAsync(AutoroutePart autoroute, IUpdateModel updater, AutoroutePartSettings settings) { if (autoroute.Path == "/") { @@ -116,9 +137,24 @@ private async Task ValidateAsync(AutoroutePart autoroute, IUpdateModel updater) updater.ModelState.AddModelError(Prefix, nameof(autoroute.Path), S["Your permalink is too long. The permalink can only be up to {0} characters.", MaxPathLength]); } - if (autoroute.Path != null && (await _session.QueryIndex(o => o.Path == autoroute.Path && o.ContentItemId != autoroute.ContentItem.ContentItemId).CountAsync()) > 0) + // This can only validate the path if the Autoroute is not managing content item routes or the path is absolute. + if (!String.IsNullOrEmpty(autoroute.Path) && (!settings.ManageContainedItemRoutes || (settings.ManageContainedItemRoutes && autoroute.Absolute))) { - updater.ModelState.AddModelError(Prefix, nameof(autoroute.Path), S["Your permalink is already in use."]); + var possibleConflicts = await _session.QueryIndex(o => o.Path == autoroute.Path).ListAsync(); + if (possibleConflicts.Any()) + { + var hasConflict = false; + // This logic is different to the check in the handler as here we checking + if (possibleConflicts.Any(x => x.ContentItemId != autoroute.ContentItem.ContentItemId) || + possibleConflicts.Any(x => !string.IsNullOrEmpty(x.ContainedContentItemId) && x.ContainedContentItemId != autoroute.ContentItem.ContentItemId)) + { + hasConflict = true; + } + if (hasConflict) + { + updater.ModelState.AddModelError(Prefix, nameof(autoroute.Path), S["Your permalink is already in use."]); + } + } } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/AutorouteContentHandler.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/AutorouteContentHandler.cs new file mode 100644 index 00000000000..9012ed71347 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/AutorouteContentHandler.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Handlers; +using OrchardCore.ContentManagement.Routing; + +namespace OrchardCore.Autoroute.Handlers +{ + public class AutorouteContentHandler : ContentHandlerBase + { + private readonly IAutorouteEntries _autorouteEntries; + + public AutorouteContentHandler(IAutorouteEntries autorouteEntries) + { + _autorouteEntries = autorouteEntries; + } + + public override Task GetContentItemAspectAsync(ContentItemAspectContext context) + { + return context.ForAsync(metadata => + { + // When a content item is contained we provide different route values when generating urls. + if (_autorouteEntries.TryGetEntryByContentItemId(context.ContentItem.ContentItemId, out var entry) && + !string.IsNullOrEmpty(entry.ContainedContentItemId)) + { + metadata.DisplayRouteValues = new RouteValueDictionary { + { "Area", "OrchardCore.Contents" }, + { "Controller", "Item" }, + { "Action", "Display" }, + { "ContentItemId", entry.ContentItemId}, + { "ContainedContentItemId", entry.ContainedContentItemId } + }; + } + + return Task.CompletedTask; + }); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/AutoroutePartHandler.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/AutoroutePartHandler.cs index 8c72ab981c3..a5d1d4f2632 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/AutoroutePartHandler.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/AutoroutePartHandler.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Fluid; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; using OrchardCore.Autoroute.Drivers; using OrchardCore.Autoroute.Models; using OrchardCore.Autoroute.ViewModels; @@ -28,6 +31,9 @@ public class AutoroutePartHandler : ContentPartHandler private readonly ISiteService _siteService; private readonly ITagCache _tagCache; private readonly ISession _session; + private readonly IServiceProvider _serviceProvider; + + private IContentManager _contentManager; public AutoroutePartHandler( IAutorouteEntries entries, @@ -36,7 +42,8 @@ public AutoroutePartHandler( IContentDefinitionManager contentDefinitionManager, ISiteService siteService, ITagCache tagCache, - ISession session) + ISession session, + IServiceProvider serviceProvider) { _entries = entries; _options = options.Value; @@ -45,42 +52,71 @@ public AutoroutePartHandler( _siteService = siteService; _tagCache = tagCache; _session = session; + _serviceProvider = serviceProvider; } public override async Task PublishedAsync(PublishContentContext context, AutoroutePart part) { - if (!String.IsNullOrWhiteSpace(part.Path)) + // Remove entry if part is disabled. + if (part.Disabled) { - _entries.AddEntry(part.ContentItem.ContentItemId, part.Path); + _entries.RemoveEntry(part.ContentItem.ContentItemId, part.Path); } - if (part.SetHomepage) + // Add parent content item path, and children, only if parent has a valid path. + if (!String.IsNullOrWhiteSpace(part.Path) && !part.Disabled) { - var site = await _siteService.LoadSiteSettingsAsync(); + var entriesToAdd = new List + { + new AutorouteEntry(part.ContentItem.ContentItemId, part.Path) + }; - if (site.HomeRoute == null) + if (part.RouteContainedItems) { - site.HomeRoute = new RouteValueDictionary(); - } + _contentManager ??= _serviceProvider.GetRequiredService(); - var homeRoute = site.HomeRoute; + var containedAspect = await _contentManager.PopulateAspectAsync(context.PublishingItem); - foreach (var entry in _options.GlobalRouteValues) - { - homeRoute[entry.Key] = entry.Value; + await PopulateContainedContentItemRoutes(entriesToAdd, part.ContentItem.ContentItemId, containedAspect, context.PublishingItem.Content as JObject, part.Path, true); } - homeRoute[_options.ContentItemIdKey] = context.ContentItem.ContentItemId; + _entries.AddEntries(entriesToAdd); + } - // Once we too the flag into account we can dismiss it. - part.SetHomepage = false; - await _siteService.UpdateSiteSettingsAsync(site); + if (!String.IsNullOrWhiteSpace(part.Path) && !part.Disabled && part.SetHomepage) + { + await SetHomeRoute(part, homeRoute => homeRoute[_options.ContentItemIdKey] = context.ContentItem.ContentItemId); } // Evict any dependent item from cache await RemoveTagAsync(part); } + private async Task SetHomeRoute(AutoroutePart part, Action action) + { + var site = await _siteService.LoadSiteSettingsAsync(); + + if (site.HomeRoute == null) + { + site.HomeRoute = new RouteValueDictionary(); + } + + var homeRoute = site.HomeRoute; + + foreach (var entry in _options.GlobalRouteValues) + { + homeRoute[entry.Key] = entry.Value; + } + + action.Invoke(homeRoute); + + // Once we took the flag into account we can dismiss it. + part.SetHomepage = false; + part.Apply(); + + await _siteService.UpdateSiteSettingsAsync(site); + } + public override Task UnpublishedAsync(PublishContentContext context, AutoroutePart part) { if (!String.IsNullOrWhiteSpace(part.Path)) @@ -108,6 +144,200 @@ public override Task RemovedAsync(RemoveContentContext context, AutoroutePart pa } public override async Task UpdatedAsync(UpdateContentContext context, AutoroutePart part) + { + await GenerateContainerPathFromPattern(part); + await GenerateContainedPathsFromPattern(context.UpdatingItem, part); + } + + public async override Task CloningAsync(CloneContentContext context, AutoroutePart part) + { + var clonedPart = context.CloneContentItem.As(); + clonedPart.Path = await GenerateUniqueAbsolutePathAsync(part.Path, context.CloneContentItem.ContentItemId); + clonedPart.SetHomepage = false; + clonedPart.Apply(); + + await GenerateContainedPathsFromPattern(context.CloneContentItem, part); + } + + public override Task GetContentItemAspectAsync(ContentItemAspectContext context, AutoroutePart part) + { + return context.ForAsync(aspect => + { + var contentTypeDefinition = _contentDefinitionManager.GetTypeDefinition(part.ContentItem.ContentType); + var contentTypePartDefinition = contentTypeDefinition.Parts.FirstOrDefault(x => String.Equals(x.PartDefinition.Name, "AutoroutePart")); + var settings = contentTypePartDefinition.GetSettings(); + if (settings.ManageContainedItemRoutes) + { + aspect.Path = part.Path; + aspect.Absolute = part.Absolute; + aspect.Disabled = part.Disabled; + } + + return Task.CompletedTask; + }); + } + + private Task RemoveTagAsync(AutoroutePart part) + { + return _tagCache.RemoveTagAsync($"slug:{part.Path}"); + } + + private async Task GenerateContainedPathsFromPattern(ContentItem contentItem, AutoroutePart part) + { + // Validate contained content item routes if container has valid path. + if (!String.IsNullOrWhiteSpace(part.Path) || !part.RouteContainedItems) + { + return; + } + + _contentManager ??= _serviceProvider.GetRequiredService(); + + var containedAspect = await _contentManager.PopulateAspectAsync(contentItem); + + // Build the entries for this content item to evaluate for duplicates. + var entries = new List(); + await PopulateContainedContentItemRoutes(entries, part.ContentItem.ContentItemId, containedAspect, contentItem.Content as JObject, part.Path); + + await ValidateContainedContentItemRoutes(entries, part.ContentItem.ContentItemId, containedAspect, contentItem.Content as JObject, part.Path); + } + + private async Task PopulateContainedContentItemRoutes(List entries, string containerContentItemId, ContainedContentItemsAspect containedContentItemsAspect, JObject content, string basePath, bool setHomepage = false) + { + foreach (var accessor in containedContentItemsAspect.Accessors) + { + var jItems = accessor.Invoke(content); + + foreach (JObject jItem in jItems) + { + var contentItem = jItem.ToObject(); + var handlerAspect = await _contentManager.PopulateAspectAsync(contentItem); + + if (!handlerAspect.Disabled) + { + var path = handlerAspect.Path; + if (!handlerAspect.Absolute) + { + path = (basePath.EndsWith('/') ? basePath : basePath + '/') + handlerAspect.Path; + } + + entries.Add(new AutorouteEntry(containerContentItemId, path, contentItem.ContentItemId, jItem.Path)); + + // Only an autoroute part, not a default handler aspect can set itself as the homepage. + var autoroutePart = contentItem.As(); + if (setHomepage && autoroutePart != null && autoroutePart.SetHomepage) + { + await SetHomeRoute(autoroutePart, homeRoute => { + homeRoute[_options.ContentItemIdKey] = containerContentItemId; + homeRoute[_options.JsonPathKey] = jItem.Path; + }); + } + } + + var itemBasePath = (basePath.EndsWith('/') ? basePath : basePath + '/') + handlerAspect.Path; + var childrenAspect = await _contentManager.PopulateAspectAsync(contentItem); + await PopulateContainedContentItemRoutes(entries, containerContentItemId, childrenAspect, jItem, itemBasePath); + } + } + } + + private async Task ValidateContainedContentItemRoutes(List entries, string containerContentItemId, ContainedContentItemsAspect containedContentItemsAspect, JObject content, string basePath) + { + foreach (var accessor in containedContentItemsAspect.Accessors) + { + var jItems = accessor.Invoke(content); + + foreach (JObject jItem in jItems) + { + var contentItem = jItem.ToObject(); + var containedAutoroutePart = contentItem.As(); + + // This is only relevant if the content items have an autoroute part as we adjust the part value as required to guarantee a unique route. + // Content items routed only through the handler aspect already guarantee uniqueness. + if (containedAutoroutePart != null && !containedAutoroutePart.Disabled) + { + var path = containedAutoroutePart.Path; + + if (containedAutoroutePart.Absolute && !await IsAbsolutePathUniqueAsync(path, contentItem.ContentItemId)) + { + path = await GenerateUniqueAbsolutePathAsync(path, contentItem.ContentItemId); + containedAutoroutePart.Path = path; + containedAutoroutePart.Apply(); + + // Merge because we have disconnected the content item from it's json owner. + jItem.Merge(contentItem.Content, new JsonMergeSettings + { + MergeArrayHandling = MergeArrayHandling.Replace, + MergeNullValueHandling = MergeNullValueHandling.Merge + }); + } + else + { + var currentItemBasePath = basePath.EndsWith('/') ? basePath : basePath + '/'; + path = currentItemBasePath + containedAutoroutePart.Path; + if (!IsRelativePathUnique(entries, path, containedAutoroutePart)) + { + path = GenerateRelativeUniquePath(entries, path, containedAutoroutePart); + // Remove base path and update part path. + containedAutoroutePart.Path = path.Substring(currentItemBasePath.Length); + containedAutoroutePart.Apply(); + + // Merge because we have disconnected the content item from it's json owner. + jItem.Merge(contentItem.Content, new JsonMergeSettings + { + MergeArrayHandling = MergeArrayHandling.Replace, + MergeNullValueHandling = MergeNullValueHandling.Merge + }); + } + + path = path.Substring(currentItemBasePath.Length); + } + + var containedItemBasePath = (basePath.EndsWith('/') ? basePath : basePath + '/') + path; + var childItemAspect = await _contentManager.PopulateAspectAsync(contentItem); + await ValidateContainedContentItemRoutes(entries, containerContentItemId, childItemAspect, jItem, containedItemBasePath); + } + } + } + } + + private bool IsRelativePathUnique(List entries, string path, AutoroutePart context) + { + var result = !entries.Any(e => context.ContentItem.ContentItemId != e.ContainedContentItemId && String.Equals(e.Path, path, StringComparison.OrdinalIgnoreCase)); + return result; + } + + private string GenerateRelativeUniquePath(List entries, string path, AutoroutePart context) + { + var version = 1; + var unversionedPath = path; + + var versionSeparatorPosition = path.LastIndexOf('-'); + if (versionSeparatorPosition > -1 && int.TryParse(path.Substring(versionSeparatorPosition).TrimStart('-'), out version)) + { + unversionedPath = path.Substring(0, versionSeparatorPosition); + } + + while (true) + { + // Unversioned length + seperator char + version length. + var quantityCharactersToTrim = unversionedPath.Length + 1 + version.ToString().Length - AutoroutePartDisplay.MaxPathLength; + if (quantityCharactersToTrim > 0) + { + unversionedPath = unversionedPath.Substring(0, unversionedPath.Length - quantityCharactersToTrim); + } + + var versionedPath = $"{unversionedPath}-{version++}"; + if (IsRelativePathUnique(entries, versionedPath, context)) + { + var entry = entries.FirstOrDefault(e => e.ContainedContentItemId == context.ContentItem.ContentItemId); + entry.Path = versionedPath; + + return versionedPath; + } + } + } + + private async Task GenerateContainerPathFromPattern(AutoroutePart part) { // Compute the Path only if it's empty if (!String.IsNullOrWhiteSpace(part.Path)) @@ -136,28 +366,15 @@ public override async Task UpdatedAsync(UpdateContentContext context, AutorouteP part.Path = part.Path.Substring(0, AutoroutePartDisplay.MaxPathLength); } - if (!await IsPathUniqueAsync(part.Path, part)) + if (!await IsAbsolutePathUniqueAsync(part.Path, part.ContentItem.ContentItemId)) { - part.Path = await GenerateUniquePathAsync(part.Path, part); + part.Path = await GenerateUniqueAbsolutePathAsync(part.Path, part.ContentItem.ContentItemId); } part.Apply(); } } - public async override Task CloningAsync(CloneContentContext context, AutoroutePart part) - { - var clonedPart = context.CloneContentItem.As(); - clonedPart.Path = await GenerateUniquePathAsync(part.Path, clonedPart); - clonedPart.SetHomepage = false; - clonedPart.Apply(); - } - - private Task RemoveTagAsync(AutoroutePart part) - { - return _tagCache.RemoveTagAsync($"slug:{part.Path}"); - } - /// /// Get the pattern from the AutoroutePartSettings property for its type /// @@ -170,7 +387,7 @@ private string GetPattern(AutoroutePart part) return pattern; } - private async Task GenerateUniquePathAsync(string path, AutoroutePart context) + private async Task GenerateUniqueAbsolutePathAsync(string path, string contentItemId) { var version = 1; var unversionedPath = path; @@ -191,16 +408,27 @@ private async Task GenerateUniquePathAsync(string path, AutoroutePart co } var versionedPath = $"{unversionedPath}-{version++}"; - if (await IsPathUniqueAsync(versionedPath, context)) + if (await IsAbsolutePathUniqueAsync(versionedPath, contentItemId)) { return versionedPath; } } } - private async Task IsPathUniqueAsync(string path, AutoroutePart context) + private async Task IsAbsolutePathUniqueAsync(string path, string contentItemId) { - return (await _session.QueryIndex(o => o.ContentItemId != context.ContentItem.ContentItemId && o.Path == path).CountAsync()) == 0; + var isUnique = true; + var possibleConflicts = await _session.QueryIndex(o => o.Path == path).ListAsync(); + if (possibleConflicts.Any()) + { + if (possibleConflicts.Any(x => x.ContentItemId != contentItemId) || + possibleConflicts.Any(x => !string.IsNullOrEmpty(x.ContainedContentItemId) && x.ContainedContentItemId != contentItemId)) + { + isUnique = false; + } + } + + return isUnique; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/DefaultRouteContentHandler.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/DefaultRouteContentHandler.cs new file mode 100644 index 00000000000..0f9c74e1334 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Handlers/DefaultRouteContentHandler.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; +using OrchardCore.ContentManagement.Handlers; +using OrchardCore.ContentManagement.Routing; +using OrchardCore.Liquid; + +namespace OrchardCore.Autoroute.Handlers +{ + public class DefaultRouteContentHandler : ContentHandlerBase + { + private readonly ISlugService _slugService; + + public DefaultRouteContentHandler(ISlugService slugService) + { + _slugService = slugService; + } + + public override Task GetContentItemAspectAsync(ContentItemAspectContext context) + { + return context.ForAsync(aspect => + { + // Only use default aspect if no other handler has set the aspect. + if (String.IsNullOrEmpty(aspect.Path)) + { + // By default contained route is content item id + display text, if present. + var path = context.ContentItem.ContentItemId; + if (!String.IsNullOrEmpty(context.ContentItem.DisplayText)) + { + path = path + "-" + context.ContentItem.DisplayText; + } + + aspect.Path = _slugService.Slugify(path); + } + + return Task.CompletedTask; + }); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Indexes/AutoroutePartIndex.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Indexes/AutoroutePartIndex.cs index db2375a4609..2660a796b8a 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Indexes/AutoroutePartIndex.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Indexes/AutoroutePartIndex.cs @@ -1,36 +1,139 @@ using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; using OrchardCore.Autoroute.Models; +using OrchardCore.ContentManagement.Routing; +using OrchardCore.Data; using YesSql.Indexes; namespace OrchardCore.ContentManagement.Records { public class AutoroutePartIndex : MapIndex { + /// + /// The container content item id. + /// public string ContentItemId { get; set; } + + /// + /// Route path. + /// public string Path { get; set; } + + /// + /// Whether this content item is published. + /// public bool Published { get; set; } + + /// + /// Whether this content item is latest. + /// + public bool Latest { get; set; } + + /// + /// Only used if content item is contained in a container. + /// + public string ContainedContentItemId { get; set; } + + /// + /// Only used if the content item is contained in a container. + /// + public string JsonPath { get; set; } } - public class AutoroutePartIndexProvider : IndexProvider + public class AutoroutePartIndexProvider : IndexProvider, IScopedIndexProvider { + private readonly IServiceProvider _serviceProvider; + + private IContentManager _contentManager; + + public AutoroutePartIndexProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + public override void Describe(DescribeContext context) { context.For() - .Map(contentItem => + .Map(async contentItem => { - var path = contentItem.As()?.Path; - if (!String.IsNullOrEmpty(path) && (contentItem.Published || contentItem.Latest)) + var part = contentItem.As(); + + if (part == null) + { + return null; + } + + var path = part?.Path; + if (String.IsNullOrEmpty(path) || part.Disabled || !(contentItem.Published || contentItem.Latest)) { - return new AutoroutePartIndex + return null; + } + + var results = new List + { + new AutoroutePartIndex { ContentItemId = contentItem.ContentItemId, Path = path, - Published = contentItem.Published - }; + Published = contentItem.Published, + Latest = contentItem.Latest + } + }; + + if (!part.RouteContainedItems) + { + return results; } - return null; + _contentManager ??= _serviceProvider.GetRequiredService(); + + var containedContentItemsAspect = await _contentManager.PopulateAspectAsync(contentItem); + + await PopulateContainedContentItemIndexes(results, contentItem, containedContentItemsAspect, contentItem.Content as JObject, part.Path); + + return results; }); } + + private async Task PopulateContainedContentItemIndexes(List results, ContentItem containerContentItem, ContainedContentItemsAspect containedContentItemsAspect, JObject content, string basePath) + { + foreach (var accessor in containedContentItemsAspect.Accessors) + { + var items = accessor.Invoke(content); + + foreach (JObject jItem in items) + { + var contentItem = jItem.ToObject(); + var handlerAspect = await _contentManager.PopulateAspectAsync(contentItem); + + if (!handlerAspect.Disabled) + { + var path = handlerAspect.Path; + if (!handlerAspect.Absolute) + { + path = (basePath.EndsWith('/') ? basePath : basePath + '/') + handlerAspect.Path; + } + + results.Add(new AutoroutePartIndex + { + ContentItemId = containerContentItem.ContentItemId, + Path = path, + Published = containerContentItem.Published, + Latest = containerContentItem.Latest, + ContainedContentItemId = contentItem.ContentItemId, + JsonPath = jItem.Path + }); + } + + var itemBasePath = (basePath.EndsWith('/') ? basePath : basePath + '/') + handlerAspect.Path; + var childrenAspect = await _contentManager.PopulateAspectAsync(contentItem); + + await PopulateContainedContentItemIndexes(results, containerContentItem, childrenAspect, jItem, itemBasePath); + } + } + } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Liquid/ContentAutorouteLiquidTemplateEventHandler.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Liquid/ContentAutorouteLiquidTemplateEventHandler.cs index 288135e64f4..d447270e783 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Liquid/ContentAutorouteLiquidTemplateEventHandler.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Liquid/ContentAutorouteLiquidTemplateEventHandler.cs @@ -29,10 +29,9 @@ public Task RenderingAsync(TemplateContext context) alias = "/" + alias; } - string contentItemId; - if (_autorouteEntries.TryGetContentItemId(alias, out contentItemId)) + if (_autorouteEntries.TryGetEntryByPath(alias, out var entry)) { - return FluidValue.Create(await _contentManager.GetAsync(contentItemId)); + return FluidValue.Create(await _contentManager.GetAsync(entry.ContentItemId, entry.JsonPath)); } return NilValue.Instance; diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Migrations.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Migrations.cs index 50d6cea563f..fdaae0f0799 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Migrations.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Migrations.cs @@ -24,20 +24,23 @@ public int Create() SchemaBuilder.CreateMapIndexTable(nameof(AutoroutePartIndex), table => table .Column("ContentItemId", c => c.WithLength(26)) + .Column("ContainedContentItemId", c => c.WithLength(26)) + .Column("JsonPath", c => c.Unlimited()) .Column("Path", col => col.WithLength(AutoroutePartDisplay.MaxPathLength)) .Column("Published") + .Column("Latest") ); SchemaBuilder.AlterTable(nameof(AutoroutePartIndex), table => table - .CreateIndex("IDX_AutoroutePartIndex_ContentItemId", "ContentItemId") + .CreateIndex("IDX_AutoroutePartIndex_ContentItemIds", "ContentItemId", "ContainedContentItemId") ); SchemaBuilder.AlterTable(nameof(AutoroutePartIndex), table => table - .CreateIndex("IDX_AutoroutePartIndex_Published", "Published") + .CreateIndex("IDX_AutoroutePartIndex_State", "Published", "Latest") ); - // Return 3 to shortcut the second migration on new content definition schemas. - return 3; + // Return 4 to shortcut the second migration on new content definition schemas. + return 4; } // Migrate PartSettings. This only needs to run on old content definition schemas. @@ -58,5 +61,39 @@ public int UpdateFrom2() return 3; } + + // This code can be removed in a later version. + public int UpdateFrom3() + { + SchemaBuilder.AlterTable(nameof(AutoroutePartIndex), table => table + .AddColumn("ContainedContentItemId", c => c.WithLength(26)) + ); + + SchemaBuilder.AlterTable(nameof(AutoroutePartIndex), table => table + .AddColumn("JsonPath", c => c.Unlimited()) + ); + + SchemaBuilder.AlterTable(nameof(AutoroutePartIndex), table => table + .AddColumn("Latest", c => c.WithDefault(false)) + ); + + SchemaBuilder.AlterTable(nameof(AutoroutePartIndex), table => table + .DropIndex("IDX_AutoroutePartIndex_ContentItemId") + ); + + SchemaBuilder.AlterTable(nameof(AutoroutePartIndex), table => table + .CreateIndex("IDX_AutoroutePartIndex_ContentItemIds", "ContentItemId", "ContainedContentItemId") + ); + + SchemaBuilder.AlterTable(nameof(AutoroutePartIndex), table => table + .DropIndex("IDX_AutoroutePartIndex_Published") + ); + + SchemaBuilder.AlterTable(nameof(AutoroutePartIndex), table => table + .CreateIndex("IDX_AutoroutePartIndex_State", "Published", "Latest") + ); + + return 4; + } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Models/AutoroutePart.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Models/AutoroutePart.cs index cb654e298e8..e7fa86bda95 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Models/AutoroutePart.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Models/AutoroutePart.cs @@ -10,5 +10,21 @@ public class AutoroutePart : ContentPart /// Whether to make the content item the homepage once it's published. /// public bool SetHomepage { get; set; } + + /// + /// Whether to disable autoroute generation for this content item. + /// When disabled the path is no longer validated, or generated. + /// + public bool Disabled { get; set; } + + /// + /// Whether to route content items contained within the content item. + /// + public bool RouteContainedItems { get; set; } + + /// + /// When this content item is contained within another is the route relative to the container route. + /// + public bool Absolute { get; set; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Models/AutoroutePartSettings.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Models/AutoroutePartSettings.cs index 124d9f716e5..0a988e0b686 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Models/AutoroutePartSettings.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Models/AutoroutePartSettings.cs @@ -24,5 +24,25 @@ public class AutoroutePartSettings /// Whether a user can request a new path if some data has changed. /// public bool AllowUpdatePath { get; set; } + + /// + /// Whether a user is allowed to disable autoute generation to content items of this type. + /// + public bool AllowDisabled { get; set; } + + /// + /// Whether to allow routing of contained content items. + /// + public bool AllowRouteContainedItems { get; set; } + + /// + /// Whether this part is managing contained item routes. + /// + public bool ManageContainedItemRoutes { get; set; } + + /// + /// Whether to allow routing of contained content items to absolute paths. + /// + public bool AllowAbsolutePath { get; set; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Routing/AutoRouteTransformer.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Routing/AutoRouteTransformer.cs index 45df5b974f9..3c7daebcb51 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Routing/AutoRouteTransformer.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Routing/AutoRouteTransformer.cs @@ -20,10 +20,18 @@ public AutoRouteTransformer(IAutorouteEntries entries, IOptions TransformAsync(HttpContext httpContext, RouteValueDictionary values) { - if (_entries.TryGetContentItemId(httpContext.Request.Path.Value, out var contentItemId)) + if (_entries.TryGetEntryByPath(httpContext.Request.Path.Value, out var entry)) { - var routeValues = new RouteValueDictionary(_options.GlobalRouteValues); - routeValues[_options.ContentItemIdKey] = contentItemId; + var routeValues = new RouteValueDictionary(_options.GlobalRouteValues) + { + [_options.ContentItemIdKey] = entry.ContentItemId + }; + + if (!string.IsNullOrEmpty(entry.JsonPath)) + { + routeValues[_options.JsonPathKey] = entry.JsonPath; + } + return new ValueTask(routeValues); } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Routing/AutorouteValuesAddressScheme.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Routing/AutorouteValuesAddressScheme.cs index e3d4c5bd3aa..921fc1623cf 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Routing/AutorouteValuesAddressScheme.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Routing/AutorouteValuesAddressScheme.cs @@ -28,15 +28,24 @@ public IEnumerable FindEndpoints(RouteValuesAddress address) return Enumerable.Empty(); } - string contentItemId = address.ExplicitValues[_options.ContentItemIdKey]?.ToString(); - if (string.IsNullOrEmpty(contentItemId) || !_entries.TryGetPath(contentItemId, out var path)) + // Try to get the contained item first, then the container content item + string contentItemId = address.ExplicitValues[_options.ContainedContentItemIdKey]?.ToString(); + if (string.IsNullOrEmpty(contentItemId)) + { + contentItemId = address.ExplicitValues[_options.ContentItemIdKey]?.ToString(); + } + + if (string.IsNullOrEmpty(contentItemId) || !_entries.TryGetEntryByContentItemId(contentItemId, out var autorouteEntry)) { return Enumerable.Empty(); } if (Match(address.ExplicitValues)) { + // Once we have the contained content item id value we no longer want it in the route values. + address.ExplicitValues.Remove(_options.ContainedContentItemIdKey); + var routeValues = new RouteValueDictionary(address.ExplicitValues); if (address.ExplicitValues.Count > _options.GlobalRouteValues.Count + 1) @@ -58,7 +67,7 @@ public IEnumerable FindEndpoints(RouteValuesAddress address) var endpoint = new RouteEndpoint ( c => null, - RoutePatternFactory.Parse(path, routeValues, null), + RoutePatternFactory.Parse(autorouteEntry.Path, routeValues, null), 0, null, null diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Services/AutorouteAliasProvider.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Services/AutorouteAliasProvider.cs index b4419bd71dc..5381c4be2e5 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Services/AutorouteAliasProvider.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Services/AutorouteAliasProvider.cs @@ -26,10 +26,11 @@ public Task GetContentItemIdAsync(string alias) alias = "/" + alias; } - string contentItemId; - if (_autorouteEntries.TryGetContentItemId(alias, out contentItemId)) + if (_autorouteEntries.TryGetEntryByPath(alias, out var entry)) { - return Task.FromResult(contentItemId); + // TODO this requires more work, and interface changes to support contained content items. + // as it will require returning the id and jsonPath. + return Task.FromResult(entry.ContentItemId); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Services/AutorouteEntries.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Services/AutorouteEntries.cs index 2d70e1b8154..c83bd2adfb0 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Services/AutorouteEntries.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Services/AutorouteEntries.cs @@ -1,44 +1,70 @@ using System; using System.Collections.Generic; +using System.Linq; using OrchardCore.ContentManagement.Routing; namespace OrchardCore.Autoroute.Services { public class AutorouteEntries : IAutorouteEntries { - private readonly Dictionary _paths; - private readonly Dictionary _contentItemIds; + private readonly Dictionary _paths; + private readonly Dictionary _contentItemIds; public AutorouteEntries() { - _paths = new Dictionary(); - _contentItemIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + _paths = new Dictionary(); + _contentItemIds = new Dictionary(StringComparer.OrdinalIgnoreCase); } - public bool TryGetContentItemId(string path, out string contentItemId) + + public bool TryGetEntryByPath(string path, out AutorouteEntry entry) { - return _contentItemIds.TryGetValue(path.TrimEnd('/'), out contentItemId); + return _contentItemIds.TryGetValue(path, out entry); } - public bool TryGetPath(string contentItemId, out string path) + public bool TryGetEntryByContentItemId(string contentItemId, out AutorouteEntry entry) { - return _paths.TryGetValue(contentItemId, out path); + return _paths.TryGetValue(contentItemId, out entry); } public void AddEntries(IEnumerable entries) { lock (this) { + // Evict all entries related to a container item from autoroute entries. + // This is necesary to account for deletions, disabling of an item, or disabling routing of contained items. + foreach (var entry in entries.Where(x => String.IsNullOrEmpty(x.ContainedContentItemId))) + { + var entriesToRemove = _paths.Values.Where(x => x.ContentItemId == entry.ContentItemId && !String.IsNullOrEmpty(x.ContainedContentItemId)); + foreach (var entryToRemove in entriesToRemove) + { + _paths.Remove(entryToRemove.ContainedContentItemId); + _contentItemIds.Remove(entryToRemove.Path); + } + } + foreach (var entry in entries) { - if (_paths.TryGetValue(entry.ContentItemId, out var previousPath)) + if (_paths.TryGetValue(entry.ContentItemId, out var previousContainerEntry) && String.IsNullOrEmpty(entry.ContainedContentItemId)) + { + _contentItemIds.Remove(previousContainerEntry.Path); + } + + if (!String.IsNullOrEmpty(entry.ContainedContentItemId) && _paths.TryGetValue(entry.ContainedContentItemId, out var previousContainedEntry)) { - _contentItemIds.Remove(previousPath); + _contentItemIds.Remove(previousContainedEntry.Path); } - var requestPath = "/" + entry.Path.Trim('/'); - _paths[entry.ContentItemId] = requestPath; - _contentItemIds[requestPath] = entry.ContentItemId; + _contentItemIds[entry.Path] = entry; + + if (!String.IsNullOrEmpty(entry.ContainedContentItemId)) + { + _paths[entry.ContainedContentItemId] = entry; + } + else + { + _paths[entry.ContentItemId] = entry; + } } } } @@ -46,9 +72,17 @@ public void AddEntries(IEnumerable entries) public void RemoveEntries(IEnumerable entries) { lock (this) - { + { foreach (var entry in entries) { + // Evict all entries related to a container item from autoroute entries. + var entriesToRemove = _paths.Values.Where(x => x.ContentItemId == entry.ContentItemId && !String.IsNullOrEmpty(x.ContainedContentItemId)); + foreach (var entryToRemove in entriesToRemove) + { + _paths.Remove(entryToRemove.ContainedContentItemId); + _contentItemIds.Remove(entryToRemove.Path); + } + _paths.Remove(entry.ContentItemId); _contentItemIds.Remove(entry.Path); } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Settings/AutoroutePartSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Settings/AutoroutePartSettingsDisplayDriver.cs index 405c09dc510..98dceca7ba2 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Settings/AutoroutePartSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Settings/AutoroutePartSettingsDisplayDriver.cs @@ -37,6 +37,10 @@ public override IDisplayResult Edit(ContentTypePartDefinition contentTypePartDef model.AllowUpdatePath = settings.AllowUpdatePath; model.Pattern = settings.Pattern; model.ShowHomepageOption = settings.ShowHomepageOption; + model.AllowDisabled = settings.AllowDisabled; + model.AllowRouteContainedItems = settings.AllowRouteContainedItems; + model.ManageContainedItemRoutes = settings.ManageContainedItemRoutes; + model.AllowAbsolutePath = settings.AllowAbsolutePath; model.AutoroutePartSettings = settings; }).Location("Content"); } @@ -54,7 +58,11 @@ await context.Updater.TryUpdateModelAsync(model, Prefix, m => m.Pattern, m => m.AllowCustomPath, m => m.AllowUpdatePath, - m => m.ShowHomepageOption); + m => m.ShowHomepageOption, + m => m.AllowDisabled, + m => m.AllowRouteContainedItems, + m => m.ManageContainedItemRoutes, + m => m.AllowAbsolutePath); if (!string.IsNullOrEmpty(model.Pattern) && !_templateManager.Validate(model.Pattern, out var errors)) { @@ -67,7 +75,11 @@ await context.Updater.TryUpdateModelAsync(model, Prefix, Pattern = model.Pattern, AllowCustomPath = model.AllowCustomPath, AllowUpdatePath = model.AllowUpdatePath, - ShowHomepageOption = model.ShowHomepageOption + ShowHomepageOption = model.ShowHomepageOption, + AllowDisabled = model.AllowDisabled, + AllowRouteContainedItems = model.AllowRouteContainedItems, + ManageContainedItemRoutes = model.ManageContainedItemRoutes, + AllowAbsolutePath = model.AllowAbsolutePath }); } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/Startup.cs index c490d9f6200..09dbb56fc7c 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Startup.cs @@ -17,9 +17,11 @@ using OrchardCore.ContentManagement; using OrchardCore.ContentManagement.Display.ContentDisplay; using OrchardCore.ContentManagement.GraphQL.Options; +using OrchardCore.ContentManagement.Handlers; using OrchardCore.ContentManagement.Records; using OrchardCore.ContentManagement.Routing; using OrchardCore.ContentTypes.Editors; +using OrchardCore.Data; using OrchardCore.Data.Migration; using OrchardCore.Indexing; using OrchardCore.Liquid; @@ -48,11 +50,13 @@ public override void ConfigureServices(IServiceCollection services) .UseDisplayDriver() .AddHandler(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddSingleton(); + services.AddScoped(); services.AddScoped(); services.AddSingleton(); @@ -78,7 +82,7 @@ public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder ro var session = serviceProvider.GetRequiredService(); var autoroutes = session.QueryIndex(o => o.Published).ListAsync().GetAwaiter().GetResult(); - entries.AddEntries(autoroutes.Select(x => new AutorouteEntry { ContentItemId = x.ContentItemId, Path = x.Path })); + entries.AddEntries(autoroutes.Select(e => new AutorouteEntry(e.ContentItemId, e.Path, e.ContainedContentItemId, e.JsonPath))); // The 1st segment prevents the transformer to be executed for the home. routes.MapDynamicControllerRoute("/{any}/{**slug}"); diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/ViewModels/AutoroutePartSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/ViewModels/AutoroutePartSettingsViewModel.cs index b69799f1489..4860f6710c7 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/ViewModels/AutoroutePartSettingsViewModel.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/ViewModels/AutoroutePartSettingsViewModel.cs @@ -8,6 +8,10 @@ public class AutoroutePartSettingsViewModel public string Pattern { get; set; } public bool ShowHomepageOption { get; set; } public bool AllowUpdatePath { get; set; } + public bool AllowDisabled { get; set; } + public bool AllowRouteContainedItems { get; set; } + public bool ManageContainedItemRoutes { get; set; } + public bool AllowAbsolutePath { get; set; } public AutoroutePartSettings AutoroutePartSettings { get; set; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/ViewModels/AutoroutePartViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Autoroute/ViewModels/AutoroutePartViewModel.cs index 42cc8f84cfe..8dbda234584 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/ViewModels/AutoroutePartViewModel.cs +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/ViewModels/AutoroutePartViewModel.cs @@ -10,6 +10,9 @@ public class AutoroutePartViewModel public bool SetHomepage { get; set; } public bool UpdatePath { get; set; } public bool IsHomepage { get; set; } + public bool RouteContainedItems { get; set; } + public bool Absolute { get; set; } + public bool Disabled { get; set; } [BindNever] public ContentItem ContentItem { get; set; } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Views/AutoroutePart.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Autoroute/Views/AutoroutePart.Edit.cshtml index 06a767734fa..e97b54b0871 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Views/AutoroutePart.Edit.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Views/AutoroutePart.Edit.cshtml @@ -1,62 +1,115 @@ @using OrchardCore.Autoroute.ViewModels; +@using OrchardCore.ContentManagement; +@using OrchardCore.ContentManagement.Routing; @model AutoroutePartViewModel @inject IAuthorizationService AuthorizationService @inject OrchardCore.Settings.ISiteService SiteService +@inject OrchardCore.ContentManagement.IContentManager ContentManager @{ var site = await SiteService.GetSiteSettingsAsync(); + var containedContentItemsAspect = await ContentManager.PopulateAspectAsync(Model.ContentItem); } -
- -
- @if (!String.IsNullOrWhiteSpace(site.BaseUrl)) +@if (Model.Settings.AllowDisabled) +{ +
+
+ + + — @T["Check to disable autoroute for this content item."] +
+
+} + +
+
+ +
+ @if (!String.IsNullOrWhiteSpace(site.BaseUrl)) + { +
+
@site.BaseUrl
+
+ } + + +
+ @if (!String.IsNullOrWhiteSpace(Model.Settings.Pattern) && Model.Settings.AllowCustomPath) { -
-
@site.BaseUrl
-
+ @T["The url of the content item. Leave empty to auto-generate it."] + } + else + { + @T["The url of the content item. It will be automatically generated."] } - -
- @if (!String.IsNullOrWhiteSpace(Model.Settings.Pattern) && Model.Settings.AllowCustomPath) - { - @T["The url of the content item. Leave empty to auto-generate it."] + @{ + var authorized = await AuthorizationService.AuthorizeAsync(User, Permissions.SetHomepage); + var showHomepageOption = Model.Settings.ShowHomepageOption && authorized; + + if (Model.IsHomepage) + { +
@T["This content item is the current homepage"]
+ } + else + { + if (showHomepageOption) + { +
+
+ + + — @T["Check to set this content item as the homepage once published."] +
+
+ } + } } - else + + @if (Model.Settings.AllowUpdatePath) { - @T["The url of the content item. It will be automatically generated."] +
+
+ + + — @T["Check to refresh the path. Warning: the previous URL won't be accessible anymore."] +
+
} -
-@{ - var authorized = await AuthorizationService.AuthorizeAsync(User, Permissions.SetHomepage); - var showHomepageOption = Model.Settings.ShowHomepageOption && authorized; - if (Model.IsHomepage) + @if (containedContentItemsAspect.Accessors.Any() && Model.Settings.AllowRouteContainedItems) { -
@T["This content item is the current homepage"]
+
+
+ + + — @T["Check to enabling the routing of child content items."] +
+
} - else + + @if (Model.Settings.ManageContainedItemRoutes && Model.Settings.AllowAbsolutePath) { - if (showHomepageOption) - { -
-
- - - — @T["Check to set this content item as the homepage once published."] -
+
+
+ + + — @T["Check to specify that a child content item will be routed to an absolute path."]
- } +
} -} +
-@if (Model.Settings.AllowUpdatePath) +@if (Model.Settings.AllowDisabled) { -
-
- - - — @T["Check to refresh the path. Warning: the previous URL won't be accessible anymore."] -
-
+ } diff --git a/src/OrchardCore.Modules/OrchardCore.Autoroute/Views/AutoroutePartSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Autoroute/Views/AutoroutePartSettings.Edit.cshtml index 459a11a801b..c96bf385d4a 100644 --- a/src/OrchardCore.Modules/OrchardCore.Autoroute/Views/AutoroutePartSettings.Edit.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Autoroute/Views/AutoroutePartSettings.Edit.cshtml @@ -21,7 +21,7 @@
- @T["Check to allow users to set a custom path, otherwise it will be automatically generated."] + — @T["Check to allow users to set a custom path, otherwise it will be automatically generated."]
@@ -29,7 +29,7 @@
- @T["Check to allow users to refresh the path once a content item is already published."] + — @T["Check to allow users to refresh the path once a content item is already published."]
@@ -37,10 +37,50 @@
- @T["Check to allow the content items of this content type to be set as the homepage. It will only be visible to users with the appropriate permission."] + — @T["Check to allow the content items of this content type to be set as the homepage. It will only be visible to users with the appropriate permission."]
+
+
+ + + — @T["Check to allow the content items of this content type to disable autoroute."] +
+
+ +
+
@T["Container Settings"] — @T["Settings for parent or child content items."]
+
+
+ + + — @T["Check to allow users to enable routing of child content items. This setting must be applied to the parent content item."] +
+
+ +
+
+ + + — @T["Check to allow this part to apply routes to child content items. This setting must be applied to the child content item."] +
+
+ +
+
+ + + +
+
+ — @T["Check to allow users to enable absolute paths for child content items. When disabled the path is relative to the parents path."] + @T["When relative : https://mysite.com/my-parent/my-child-item."] + @T["When absolute : https://mysite.com/my-child-item."] +
+
+
+ diff --git a/src/OrchardCore.Modules/OrchardCore.ContentLocalization/Services/CulturePickerService.cs b/src/OrchardCore.Modules/OrchardCore.ContentLocalization/Services/CulturePickerService.cs index 73bb1a933f7..0cace8ea73e 100644 --- a/src/OrchardCore.Modules/OrchardCore.ContentLocalization/Services/CulturePickerService.cs +++ b/src/OrchardCore.Modules/OrchardCore.ContentLocalization/Services/CulturePickerService.cs @@ -31,7 +31,7 @@ public async Task GetContentItemIdFromRouteAsync(PathString url) url = "/"; } - string contentItemId; + string contentItemId = null; if (url == "/") { @@ -41,8 +41,12 @@ public async Task GetContentItemIdFromRouteAsync(PathString url) } else { - // try to get from autorouteEntries - _autorouteEntries.TryGetContentItemId(url.Value, out contentItemId); + // Try to get from autorouteEntries. + // This should not consider contained items, so will redirect to the parent item. + if (_autorouteEntries.TryGetEntryByPath(url.Value, out var entry)) + { + contentItemId = entry.ContentItemId; + }; } if (string.IsNullOrEmpty(contentItemId)) diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Controllers/ItemController.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Controllers/ItemController.cs index 4fa373ac8a4..3d6b540e6b1 100644 --- a/src/OrchardCore.Modules/OrchardCore.Contents/Controllers/ItemController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Controllers/ItemController.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; using OrchardCore.ContentManagement; using OrchardCore.ContentManagement.Display; using OrchardCore.DisplayManagement.ModelBinding; @@ -27,14 +28,14 @@ public ItemController( _updateModelAccessor = updateModelAccessor; } - public async Task Display(string contentItemId) + public async Task Display(string contentItemId, string jsonPath) { if (contentItemId == null) { return NotFound(); } - var contentItem = await _contentManager.GetAsync(contentItemId); + var contentItem = await _contentManager.GetAsync(contentItemId, jsonPath); if (contentItem == null) { diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Startup.cs index 859e2b73fce..a5f43ebeffd 100644 --- a/src/OrchardCore.Modules/OrchardCore.Contents/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Startup.cs @@ -108,6 +108,8 @@ public override void ConfigureServices(IServiceCollection services) }; options.ContentItemIdKey = "contentItemId"; + options.ContainedContentItemIdKey = "containedContentItemId"; + options.JsonPathKey = "jsonPath"; } }); } diff --git a/src/OrchardCore.Modules/OrchardCore.Flows/Drivers/BagPartDisplay.cs b/src/OrchardCore.Modules/OrchardCore.Flows/Drivers/BagPartDisplay.cs index add7564580a..04ff5d46e32 100644 --- a/src/OrchardCore.Modules/OrchardCore.Flows/Drivers/BagPartDisplay.cs +++ b/src/OrchardCore.Modules/OrchardCore.Flows/Drivers/BagPartDisplay.cs @@ -62,7 +62,6 @@ public override async Task UpdateAsync(BagPart part, UpdatePartE await context.Updater.TryUpdateModelAsync(model, Prefix); - //part.ContentItems.Clear(); var contentItems = new List(); for (var i = 0; i < model.Prefixes.Length; i++) diff --git a/src/OrchardCore.Modules/OrchardCore.Flows/Handlers/BagPartHandler.cs b/src/OrchardCore.Modules/OrchardCore.Flows/Handlers/BagPartHandler.cs new file mode 100644 index 00000000000..88b8746b741 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Flows/Handlers/BagPartHandler.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using OrchardCore.ContentManagement.Handlers; +using OrchardCore.ContentManagement.Routing; +using OrchardCore.Flows.Models; + +namespace OrchardCore.Flows.Handlers +{ + public class BagPartHandler : ContentPartHandler + { + public override Task GetContentItemAspectAsync(ContentItemAspectContext context, BagPart part) + { + return context.ForAsync(aspect => + { + aspect.Accessors.Add((jObject) => + { + // Content.Path contains the accessor for named bag parts and typed bag parts. + var jContent = part.Content as JObject; + return jObject[jContent.Path]["ContentItems"] as JArray; + }); + + return Task.CompletedTask; + }); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Flows/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Flows/Startup.cs index 049c4c2cfa9..b7c231c0786 100644 --- a/src/OrchardCore.Modules/OrchardCore.Flows/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Flows/Startup.cs @@ -11,6 +11,7 @@ using OrchardCore.Data.Migration; using OrchardCore.Flows.Controllers; using OrchardCore.Flows.Drivers; +using OrchardCore.Flows.Handlers; using OrchardCore.Flows.Indexing; using OrchardCore.Flows.Models; using OrchardCore.Flows.Settings; @@ -48,7 +49,8 @@ public override void ConfigureServices(IServiceCollection services) // Bag Part services.AddContentPart() - .UseDisplayDriver(); + .UseDisplayDriver() + .AddHandler(); services.AddScoped(); services.AddScoped(); diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Controllers/AdminController.cs index 9bc5734dcc6..26c42d6b474 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Controllers/AdminController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Controllers/AdminController.cs @@ -59,6 +59,8 @@ public async Task Create(string id, string taxonomyContentItemId, } var contentItem = await _contentManager.NewAsync(id); + contentItem.Weld(); + contentItem.Alter(t => t.TaxonomyContentItemId = taxonomyContentItemId); dynamic model = await _contentItemDisplayManager.BuildEditorAsync(contentItem, _updateModelAccessor.ModelUpdater, true); @@ -96,6 +98,8 @@ public async Task CreatePost(string id, string taxonomyContentIte } var contentItem = await _contentManager.NewAsync(id); + contentItem.Weld(); + contentItem.Alter(t => t.TaxonomyContentItemId = taxonomyContentItemId); dynamic model = await _contentItemDisplayManager.UpdateEditorAsync(contentItem, _updateModelAccessor.ModelUpdater, true); @@ -162,6 +166,8 @@ public async Task Edit(string taxonomyContentItemId, string taxon } var contentItem = taxonomyItem.ToObject(); + contentItem.Weld(); + contentItem.Alter(t => t.TaxonomyContentItemId = taxonomyContentItemId); dynamic model = await _contentItemDisplayManager.BuildEditorAsync(contentItem, _updateModelAccessor.ModelUpdater, false); @@ -208,6 +214,8 @@ public async Task EditPost(string taxonomyContentItemId, string t } var contentItem = taxonomyItem.ToObject(); + contentItem.Weld(); + contentItem.Alter(t => t.TaxonomyContentItemId = taxonomyContentItemId); dynamic model = await _contentItemDisplayManager.UpdateEditorAsync(contentItem, _updateModelAccessor.ModelUpdater, false); diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Controllers/TagController.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Controllers/TagController.cs index e5edba4d735..5e103027984 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Controllers/TagController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Controllers/TagController.cs @@ -1,11 +1,16 @@ +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using OrchardCore.Admin; using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Handlers; using OrchardCore.ContentManagement.Metadata; using OrchardCore.ContentManagement.Metadata.Settings; using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.Modules; using OrchardCore.Taxonomies.Models; using OrchardCore.Taxonomies.ViewModels; using YesSql; @@ -18,18 +23,24 @@ public class TagController : Controller, IUpdateModel private readonly IContentManager _contentManager; private readonly IAuthorizationService _authorizationService; private readonly IContentDefinitionManager _contentDefinitionManager; + private readonly IEnumerable _contentHandlers; private readonly ISession _session; + private readonly ILogger _logger; public TagController( - ISession session, IContentManager contentManager, IAuthorizationService authorizationService, - IContentDefinitionManager contentDefinitionManager) + IContentDefinitionManager contentDefinitionManager, + IEnumerable contentHandlers, + ISession session, + ILogger logger) { _contentManager = contentManager; _authorizationService = authorizationService; _contentDefinitionManager = contentDefinitionManager; + _contentHandlers = contentHandlers; _session = session; + _logger = logger; } [HttpPost] @@ -61,12 +72,18 @@ public async Task CreatePost(string taxonomyContentItemId, string var part = taxonomy.As(); - // Create tag term without running content item display manager update editor. - // This creates empty parts, if parts are attached to the tag term, with no data. - // This allows parts that have validation = required to still be created, and - // later edited with the taxonomy editor. + // Create tag term but only run content handlers not content item display manager update editor. + // This creates empty parts, if parts are attached to the tag term, with empty data. + // But still generates valid autoroute paths from the handler. var contentItem = await _contentManager.NewAsync(part.TermContentType); contentItem.DisplayText = displayText; + contentItem.Weld(); + contentItem.Alter(t => t.TaxonomyContentItemId = taxonomyContentItemId); + + var updateContentContext = new UpdateContentContext(contentItem); + + await _contentHandlers.InvokeAsync((handler, updateContentContext) => handler.UpdatingAsync(updateContentContext), updateContentContext, _logger); + await _contentHandlers.Reverse().InvokeAsync((handler, updateContentContext) => handler.UpdatedAsync(updateContentContext), updateContentContext, _logger); if (!ModelState.IsValid) { diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TaxonomyFieldDriverHelper.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TaxonomyFieldDriverHelper.cs index 48e626bddca..5c9c642255e 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TaxonomyFieldDriverHelper.cs +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TaxonomyFieldDriverHelper.cs @@ -3,6 +3,7 @@ using System.Linq; using Newtonsoft.Json.Linq; using OrchardCore.ContentManagement; +using OrchardCore.DisplayManagement.Handlers; using OrchardCore.Taxonomies.Fields; using OrchardCore.Taxonomies.ViewModels; diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TaxonomyPartDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TaxonomyPartDisplayDriver.cs index b86169db170..180be8c09e0 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TaxonomyPartDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TaxonomyPartDisplayDriver.cs @@ -6,7 +6,7 @@ using Newtonsoft.Json.Linq; using OrchardCore.ContentManagement; using OrchardCore.ContentManagement.Display.ContentDisplay; -using OrchardCore.ContentManagement.Metadata; +using OrchardCore.ContentManagement.Display.Models; using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.DisplayManagement.Views; using OrchardCore.Taxonomies.Models; @@ -16,19 +16,15 @@ namespace OrchardCore.Taxonomies.Drivers { public class TaxonomyPartDisplayDriver : ContentPartDisplayDriver { - private readonly IContentManager _contentManager; - private readonly IServiceProvider _serviceProvider; - private readonly IContentDefinitionManager _contentDefinitionManager; - - public TaxonomyPartDisplayDriver( - IContentDefinitionManager contentDefinitionManager, - IContentManager contentManager, - IServiceProvider serviceProvider - ) + public override IDisplayResult Display(TaxonomyPart part, BuildPartDisplayContext context) { - _contentDefinitionManager = contentDefinitionManager; - _serviceProvider = serviceProvider; - _contentManager = contentManager; + var hasItems = part.Terms.Any(); + return Initialize(hasItems ? "TaxonomyPart" : "TaxonomyPart_Empty", m => + { + m.ContentItem = part.ContentItem; + m.TaxonomyPart = part; + }) + .Location("Detail", "Content:5"); } public override IDisplayResult Edit(TaxonomyPart part) diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TermPartContentDriver.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TermPartContentDriver.cs new file mode 100644 index 00000000000..3b601c66d53 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Drivers/TermPartContentDriver.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Display.ContentDisplay; +using OrchardCore.ContentManagement.Records; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Navigation; +using OrchardCore.Settings; +using OrchardCore.Taxonomies.Indexing; +using OrchardCore.Taxonomies.Models; +using OrchardCore.Taxonomies.ViewModels; +using YesSql; + +namespace OrchardCore.Taxonomies.Drivers +{ + public class TermPartContentDriver : ContentDisplayDriver + { + private readonly ISession _session; + private readonly ISiteService _siteService; + private readonly IContentManager _contentManager; + + public TermPartContentDriver( + ISession session, + ISiteService siteService, + IContentManager contentManager) + { + _session = session; + _siteService = siteService; + _contentManager = contentManager; + } + + public override Task DisplayAsync(ContentItem model, BuildDisplayContext context) + { + var part = model.As(); + if (part != null) + { + return Task.FromResult(Initialize("TermPart", async m => + { + var siteSettings = await _siteService.GetSiteSettingsAsync(); + var pager = await GetPagerAsync(context.Updater, siteSettings.PageSize); + m.TaxonomyContentItemId = part.TaxonomyContentItemId; + m.ContentItem = part.ContentItem; + m.ContentItems = (await QueryTermItemsAsync(part, pager)).ToArray(); + m.Pager = await context.New.PagerSlim(pager); + }) + .Location("Detail", "Content:5")); + } + + return Task.FromResult(null); + } + + private async Task> QueryTermItemsAsync(TermPart termPart, PagerSlim pager) + { + if (pager.Before != null) + { + var beforeValue = new DateTime(long.Parse(pager.Before)); + var query = _session.Query() + .With(x => x.TermContentItemId == termPart.ContentItem.ContentItemId) + .With(CreateContentIndexFilter(beforeValue, null)) + .OrderBy(x => x.CreatedUtc) + .Take(pager.PageSize + 1); + + var containedItems = await query.ListAsync(); + + if (containedItems.Count() == 0) + { + return containedItems; + } + + containedItems = containedItems.Reverse(); + + // There is always an After as we clicked on Before + pager.Before = null; + pager.After = containedItems.Last().CreatedUtc.Value.Ticks.ToString(); + + if (containedItems.Count() == pager.PageSize + 1) + { + containedItems = containedItems.Skip(1); + pager.Before = containedItems.First().CreatedUtc.Value.Ticks.ToString(); + } + + return await _contentManager.LoadAsync(containedItems); + } + else if (pager.After != null) + { + var afterValue = new DateTime(long.Parse(pager.After)); + var query = _session.Query() + .With(x => x.TermContentItemId == termPart.ContentItem.ContentItemId) + .With(CreateContentIndexFilter(null, afterValue)) + .OrderByDescending(x => x.CreatedUtc) + .Take(pager.PageSize + 1); + + var containedItems = await query.ListAsync(); + + if (containedItems.Count() == 0) + { + return containedItems; + } + + // There is always a Before page as we clicked on After + pager.Before = containedItems.First().CreatedUtc.Value.Ticks.ToString(); + pager.After = null; + + if (containedItems.Count() == pager.PageSize + 1) + { + containedItems = containedItems.Take(pager.PageSize); + pager.After = containedItems.Last().CreatedUtc.Value.Ticks.ToString(); + } + + return await _contentManager.LoadAsync(containedItems); + } + else + { + var query = _session.Query() + .With(x => x.TermContentItemId == termPart.ContentItem.ContentItemId) + .With(CreateContentIndexFilter(null, null)) + .OrderByDescending(x => x.CreatedUtc) + .Take(pager.PageSize + 1); + + var containedItems = await query.ListAsync(); + + if (containedItems.Count() == 0) + { + return containedItems; + } + + pager.Before = null; + pager.After = null; + + if (containedItems.Count() == pager.PageSize + 1) + { + containedItems = containedItems.Take(pager.PageSize); + pager.After = containedItems.Last().CreatedUtc.Value.Ticks.ToString(); + } + + return await _contentManager.LoadAsync(containedItems); + } + } + + private static async Task GetPagerAsync(IUpdateModel updater, int pageSize) + { + var pagerParameters = new PagerSlimParameters(); + await updater.TryUpdateModelAsync(pagerParameters); + + var pager = new PagerSlim(pagerParameters, pageSize); + + return pager; + } + + private static Expression> CreateContentIndexFilter(DateTime? before, DateTime? after) + { + if (before != null) + { + return x => x.Published && x.CreatedUtc > before; + } + + if (after != null) + { + return x => x.Published && x.CreatedUtc < after; + } + + return x => x.Published; + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Handlers/TaxonomyPartHandler.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Handlers/TaxonomyPartHandler.cs new file mode 100644 index 00000000000..2dffb5654d1 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Handlers/TaxonomyPartHandler.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using OrchardCore.ContentManagement.Handlers; +using OrchardCore.ContentManagement.Routing; +using OrchardCore.Taxonomies.Models; + +namespace OrchardCore.Taxonomies.Handlers +{ + public class TaxonomyPartHandler : ContentPartHandler + { + public override Task GetContentItemAspectAsync(ContentItemAspectContext context, TaxonomyPart part) + { + return context.ForAsync(aspect => + { + aspect.Accessors.Add((jObject) => + { + return jObject["TaxonomyPart"]["Terms"] as JArray; + }); + + return Task.CompletedTask; + }); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Handlers/TermPartContentHandler.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Handlers/TermPartContentHandler.cs new file mode 100644 index 00000000000..6ed2f368e4a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Handlers/TermPartContentHandler.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using OrchardCore.ContentManagement.Handlers; +using OrchardCore.ContentManagement.Routing; + +namespace OrchardCore.Taxonomies.Handlers +{ + public class TermPartContentHandler : ContentHandlerBase + { + public override Task GetContentItemAspectAsync(ContentItemAspectContext context) + { + return context.ForAsync(aspect => + { + // Check this content item contains Terms. + if (context.ContentItem.Content.Terms is JArray children) + { + aspect.Accessors.Add((jObject) => + { + return jObject["Terms"] as JArray; + }); + } + + return Task.CompletedTask; + }); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Liquid/TaxonomyTermFilter.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Liquid/TaxonomyTermFilter.cs index 0e675411161..69c52d39b80 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Liquid/TaxonomyTermFilter.cs +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Liquid/TaxonomyTermFilter.cs @@ -61,7 +61,11 @@ public async ValueTask ProcessAsync(FluidValue input, FilterArgument foreach (var termContentItemId in termContentItemIds) { var term = TaxonomyOrchardHelperExtensions.FindTerm(taxonomy.Content.TaxonomyPart.Terms as JArray, termContentItemId); - terms.Add(term); + + if (term != null) + { + terms.Add(term); + } } return FluidValue.Create(terms); diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Migrations.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Migrations.cs index db60d43cbd0..2a333390047 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Migrations.cs +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Migrations.cs @@ -19,14 +19,26 @@ public Migrations(IContentDefinitionManager contentDefinitionManager) public int Create() { - _contentDefinitionManager.AlterTypeDefinition("Taxonomy", menu => menu + _contentDefinitionManager.AlterTypeDefinition("Taxonomy", taxonomy => taxonomy .Draftable() .Versionable() .Creatable() .Listable() .WithPart("TitlePart", part => part.WithPosition("1")) - .WithPart("AliasPart", part => part.WithPosition("2").WithSettings(new AliasPartSettings { Pattern = "{{ Model.ContentItem | display_text | slugify }}" })) - .WithPart("TaxonomyPart", part => part.WithPosition("3")) + .WithPart("AliasPart", part => part + .WithPosition("2") + .WithSettings(new AliasPartSettings + { + Pattern = "{{ Model.ContentItem | display_text | slugify }}" + })) + .WithPart("AutoroutePart", part => part + .WithPosition("3") + .WithSettings(new AutoroutePartSettings + { + Pattern = "{{ Model.ContentItem | display_text | slugify }}", + AllowRouteContainedItems = true + })) + .WithPart("TaxonomyPart", part => part.WithPosition("4")) ); SchemaBuilder.CreateMapIndexTable(nameof(TaxonomyIndex), table => table @@ -46,8 +58,8 @@ public int Create() .CreateIndex("IDX_TaxonomyIndex_Search", "TermContentItemId") ); - // Return 2 to shortcut the second migration on new content definition schemas. - return 1; + // Return 3 to shortcut the migrations on new content definition schemas. + return 3; } // Migrate FieldSettings. This only needs to run on old content definition schemas. @@ -57,10 +69,32 @@ public int UpdateFrom1() _contentDefinitionManager.MigrateFieldSettings(); return 2; } + + public int UpdateFrom2() + { + _contentDefinitionManager.AlterTypeDefinition("Taxonomy", taxonomy => taxonomy + .WithPart("AutoroutePart", part => part + .WithPosition("3") + .WithSettings(new AutoroutePartSettings + { + Pattern = "{{ Model.ContentItem | display_text | slugify }}", + AllowRouteContainedItems = true + })) + .WithPart("TaxonomyPart", part => part.WithPosition("4")) + ); + + return 3; + } } internal class AliasPartSettings { public string Pattern { get; set; } } + + internal class AutoroutePartSettings + { + public string Pattern { get; set; } + public bool AllowRouteContainedItems { get; set; } + } } diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Models/TermPart.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Models/TermPart.cs index 31635abdd0b..796bb8f7fc6 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Models/TermPart.cs +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Models/TermPart.cs @@ -5,6 +5,6 @@ namespace OrchardCore.Taxonomies.Models // This part is added automatically to all terms public class TermPart : ContentPart { - public string Handle { get; set; } + public string TaxonomyContentItemId { get; set; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/OrchardCore.Taxonomies.csproj b/src/OrchardCore.Modules/OrchardCore.Taxonomies/OrchardCore.Taxonomies.csproj index 4a1eccee130..aa3a00fa4fc 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/OrchardCore.Taxonomies.csproj +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/OrchardCore.Taxonomies.csproj @@ -21,6 +21,7 @@ + diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Startup.cs index 9778395acce..a1ca2b8ef45 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Startup.cs @@ -8,9 +8,11 @@ using OrchardCore.Apis; using OrchardCore.ContentManagement; using OrchardCore.ContentManagement.Display.ContentDisplay; +using OrchardCore.ContentManagement.Handlers; using OrchardCore.ContentTypes.Editors; using OrchardCore.Data; using OrchardCore.Data.Migration; +using OrchardCore.DisplayManagement.Descriptors; using OrchardCore.Indexing; using OrchardCore.Liquid; using OrchardCore.Modules; @@ -20,6 +22,7 @@ using OrchardCore.Taxonomies.Drivers; using OrchardCore.Taxonomies.Fields; using OrchardCore.Taxonomies.GraphQL; +using OrchardCore.Taxonomies.Handlers; using OrchardCore.Taxonomies.Indexing; using OrchardCore.Taxonomies.Liquid; using OrchardCore.Taxonomies.Models; @@ -50,11 +53,13 @@ public Startup(IOptions adminOptions) public override void ConfigureServices(IServiceCollection services) { services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Taxonomy Part services.AddContentPart() - .UseDisplayDriver(); + .UseDisplayDriver() + .AddHandler(); // Taxonomy Field services.AddContentField() @@ -70,6 +75,11 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); + + // Terms + services.AddContentPart(); + services.AddScoped(); + services.AddScoped(); } public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/TermShapes.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/TermShapes.cs new file mode 100644 index 00000000000..95e04f44817 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/TermShapes.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Metadata; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.Descriptors; +using OrchardCore.Mvc.Utilities; +using OrchardCore.Taxonomies.Models; + +namespace OrchardCore.Taxonomies +{ + public class TermShapes : IShapeTableProvider + { + public void Discover(ShapeTableBuilder builder) + { + // Add standard alternates to a TermPart because it is rendered by a content display driver not a part display driver + builder.Describe("TermPart") + .OnDisplaying(context => + { + dynamic shape = context.Shape; + + var contentType = shape.ContentItem.ContentType; + var displayTypes = new[] { "", "_" + context.Shape.Metadata.DisplayType }; + + // [ShapeType]_[DisplayType], e.g. TermPart.Summary, TermPart.Detail + context.Shape.Metadata.Alternates.Add($"TermPart_{context.Shape.Metadata.DisplayType}"); + + foreach (var displayType in displayTypes) + { + // [ContentType]_[DisplayType]__[PartType], e.g. Category-TermPart, Category-TermPart.Detail + context.Shape.Metadata.Alternates.Add($"{contentType}{displayType}__TermPart"); + } + }); + + builder.Describe("Term") + .OnProcessing(async context => + { + dynamic termShape = context.Shape; + string identifier = termShape.TaxonomyContentItemId ?? termShape.Alias; + + if (String.IsNullOrEmpty(identifier)) + { + return; + } + + termShape.Classes.Add("term"); + + // Term population is executed when processing the shape so that its value + // can be cached. IShapeDisplayEvents is called before the ShapeDescriptor + // events and thus this code can be cached. + + var shapeFactory = context.ServiceProvider.GetRequiredService(); + var contentManager = context.ServiceProvider.GetRequiredService(); + var aliasManager = context.ServiceProvider.GetRequiredService(); + var orchardHelper = context.ServiceProvider.GetRequiredService(); + var contentDefinitionManager = context.ServiceProvider.GetRequiredService(); + + string taxonomyContentItemId = termShape.Alias != null + ? await aliasManager.GetContentItemIdAsync(termShape.Alias) + : termShape.TaxonomyContentItemId; + + if (taxonomyContentItemId == null) + { + return; + } + + var taxonomyContentItem = await contentManager.GetAsync(taxonomyContentItemId); + + if (taxonomyContentItem == null) + { + return; + } + + termShape.TaxonomyContentItem = taxonomyContentItem; + termShape.TaxonomyName = taxonomyContentItem.DisplayText; + + var taxonomyPart = taxonomyContentItem.As(); + if (taxonomyPart == null) + { + return; + } + + // When a TermContentItemId is provided render the term and its child terms. + var level = 0; + List termItems = null; + string termContentItemId = termShape.TermContentItemId; + if (!String.IsNullOrEmpty(termContentItemId)) + { + level = FindTerm(taxonomyContentItem.Content.TaxonomyPart.Terms as JArray, termContentItemId, level, out var termContentItem); + + if (termContentItem == null) + { + return; + } + + termItems = new List + { + termContentItem + }; + } + else + { + termItems = taxonomyPart.Terms; + } + + if (termItems == null) + { + return; + } + + string differentiator = FormatName((string)termShape.TaxonomyName); + + if (!String.IsNullOrEmpty(differentiator)) + { + // Term__[Differentiator] e.g. Term-Categories, Term-Tags + termShape.Metadata.Alternates.Add("Term__" + differentiator); + termShape.Differentiator = differentiator; + termShape.Classes.Add(("term-" + differentiator).HtmlClassify()); + } + + termShape.Classes.Add(("term-" + taxonomyPart.TermContentType).HtmlClassify()); + + var encodedContentType = EncodeAlternateElement(taxonomyPart.TermContentType); + // Term__[ContentType] e.g. Term-Category, Term-Tag + termShape.Metadata.Alternates.Add("Term__" + encodedContentType); + + // The first level of term item shapes is created. + // Each other level is created when the term item is displayed. + + foreach (var termContentItem in termItems) + { + ContentItem[] childTerms = null; + if (termContentItem.Content.Terms is JArray termsArray) + { + childTerms = termsArray.ToObject(); + } + + var shape = await shapeFactory.CreateAsync("TermItem", Arguments.From(new + { + Level = level, + Term = termShape, + TermContentItem = termContentItem, + Terms = childTerms ?? Array.Empty(), + TaxonomyContentItem = taxonomyContentItem, + Differentiator = differentiator + })); + + // Don't use Items.Add() or the collection won't be sorted + termShape.Add(shape); + } + }); + + builder.Describe("TermItem") + .OnDisplaying(async context => + { + dynamic termItem = context.Shape; + var termShape = termItem.Term; + int level = termItem.Level; + ContentItem taxonomyContentItem = termItem.TaxonomyContentItem; + var taxonomyPart = taxonomyContentItem.As(); + string differentiator = termItem.Differentiator; + + var shapeFactory = context.ServiceProvider.GetRequiredService(); + + if (termItem.Terms != null) + { + foreach (var termContentItem in termItem.Terms) + { + ContentItem[] childTerms = null; + if (termContentItem.Content.Terms is JArray termsArray) + { + childTerms = termsArray.ToObject(); + } + var shape = await shapeFactory.CreateAsync("TermItem", Arguments.From(new + { + Level = level + 1, + TaxonomyContentItem = taxonomyContentItem, + Differentiator = differentiator, + TermContentItem = termContentItem, + Term = termShape, + Terms = childTerms ?? Array.Empty() + })); + + // Don't use Items.Add() or the collection won't be sorted + termItem.Add(shape); + } + } + + var encodedContentType = EncodeAlternateElement(taxonomyPart.TermContentType); + + // TermItem__level__[level] e.g. TermItem-level-2 + termItem.Metadata.Alternates.Add("TermItem__level__" + level); + + // TermItem__[ContentType] e.g. TermItem-Category + // TermItem__[ContentType]__level__[level] e.g. TermItem-Category-level-2 + termItem.Metadata.Alternates.Add("TermItem__" + encodedContentType); + termItem.Metadata.Alternates.Add("TermItem__" + encodedContentType + "__level__" + level); + + if (!String.IsNullOrEmpty(differentiator)) + { + // TermItem__[Differentiator] e.g. TermItem-Categories, TermItem-Travel + // TermItem__[Differentiator]__level__[level] e.g. TermItem-Categories-level-2 + termItem.Metadata.Alternates.Add("TermItem__" + differentiator); + termItem.Metadata.Alternates.Add("TermItem__" + differentiator + "__level__" + level); + + // TermItem__[Differentiator]__[ContentType] e.g. TermItem-Categories-Category + // TermItem__[Differentiator]__[ContentType]__level__[level] e.g. TermItem-Categories-Category-level-2 + termItem.Metadata.Alternates.Add("TermItem__" + differentiator + "__" + encodedContentType); + termItem.Metadata.Alternates.Add("TermItem__" + differentiator + "__" + encodedContentType + "__level__" + level); + } + }); + + builder.Describe("TermContentItem") + .OnDisplaying(displaying => + { + dynamic termItem = displaying.Shape; + int level = termItem.Level; + string differentiator = termItem.Differentiator; + + ContentItem termContentItem = termItem.TermContentItem; + + var encodedContentType = EncodeAlternateElement(termContentItem.ContentItem.ContentType); + + termItem.Metadata.Alternates.Add("TermContentItem__level__" + level); + + // TermContentItem__[ContentType] e.g. TermContentItem-Category + // TermContentItem__[ContentType]__level__[level] e.g. TermContentItem-Category-level-2 + termItem.Metadata.Alternates.Add("TermContentItem__" + encodedContentType); + termItem.Metadata.Alternates.Add("TermContentItem__" + encodedContentType + "__level__" + level); + + if (!String.IsNullOrEmpty(differentiator)) + { + // TermContentItem__[Differentiator] e.g. TermContentItem-Categories + termItem.Metadata.Alternates.Add("TermContentItem__" + differentiator); + // TermContentItem__[Differentiator]__level__[level] e.g. TermContentItem-Categories-level-2 + termItem.Metadata.Alternates.Add("TermContentItem__" + differentiator + "__level__" + level); + + // TermContentItem__[Differentiator]__[ContentType] e.g. TermContentItem-Categories-Category + // TermContentItem__[Differentiator]__[ContentType] e.g. TermContentItem-Categories-Category-level-2 + termItem.Metadata.Alternates.Add("TermContentItem__" + differentiator + "__" + encodedContentType); + termItem.Metadata.Alternates.Add("TermContentItem__" + differentiator + "__" + encodedContentType + "__level__" + level); + } + }); + } + + private int FindTerm(JArray termsArray, string termContentItemId, int level, out ContentItem contentItem) + { + foreach (JObject term in termsArray) + { + var contentItemId = term.GetValue("ContentItemId").ToString(); + + if (contentItemId == termContentItemId) + { + contentItem = term.ToObject(); + return level; + } + + if (term.GetValue("Terms") is JArray children) + { + level += 1; + level = FindTerm(children, termContentItemId, level, out var foundContentItem); + + if (foundContentItem != null) + { + contentItem = foundContentItem; + return level; + } + } + } + contentItem = null; + + return level; + } + + /// + /// Encodes dashed and dots so that they don't conflict in filenames + /// + /// + /// + private string EncodeAlternateElement(string alternateElement) + { + return alternateElement.Replace("-", "__").Replace('.', '_'); + } + + /// + /// Converts "foo-ba r" to "FooBaR" + /// + private static string FormatName(string name) + { + if (String.IsNullOrEmpty(name)) + { + return null; + } + + name = name.Trim(); + var nextIsUpper = true; + var result = new StringBuilder(name.Length); + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + + if (c == '-' || char.IsWhiteSpace(c)) + { + nextIsUpper = true; + continue; + } + + if (nextIsUpper) + { + result.Append(c.ToString().ToUpper()); + } + else + { + result.Append(c); + } + + nextIsUpper = false; + } + + return result.ToString(); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/ViewModels/TaxonomyPartViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/ViewModels/TaxonomyPartViewModel.cs new file mode 100644 index 00000000000..85400c4b276 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/ViewModels/TaxonomyPartViewModel.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using OrchardCore.ContentManagement; +using OrchardCore.Taxonomies.Models; + +namespace OrchardCore.Taxonomies.ViewModels +{ + public class TaxonomyPartViewModel + { + public string TaxonomyContentItemId => ContentItem.ContentItemId; + + [BindNever] + public ContentItem ContentItem { get; set; } + + [BindNever] + public TaxonomyPart TaxonomyPart { get; set; } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/ViewModels/TermPartViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Taxonomies/ViewModels/TermPartViewModel.cs new file mode 100644 index 00000000000..5fc1ac3cf8c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/ViewModels/TermPartViewModel.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using OrchardCore.ContentManagement; + +namespace OrchardCore.Taxonomies.ViewModels +{ + public class TermPartViewModel + { + public string TermContentItemId => ContentItem.ContentItemId; + public string TaxonomyContentItemId {get; set;} + public IEnumerable ContentItems { get; set; } + public dynamic Pager { get; set; } + + [BindNever] + public ContentItem ContentItem { get; set; } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Content.TermAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Content.TermAdmin.cshtml index ed91fdf0517..817a9d97167 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Content.TermAdmin.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Content.TermAdmin.cshtml @@ -1,11 +1,3 @@ -@* - Main shape for Admin display types of MenuItem stereotype items, which is used to render - a menu item in the hierarchy editor. - It is recursive so that if the menu item has MenuItemsListPart the sub items will also - be rendered. -*@ - -@using Newtonsoft.Json; @using Newtonsoft.Json.Linq; @inject OrchardCore.ContentManagement.Display.IContentItemDisplayManager ContentItemDisplayManager diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/ContentPart.TermAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/ContentPart.TermAdmin.cshtml deleted file mode 100644 index 9c61a287e45..00000000000 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/ContentPart.TermAdmin.cshtml +++ /dev/null @@ -1,15 +0,0 @@ -@using OrchardCore.Mvc.Utilities - -@{ - string name = Model.Metadata.Name; -} - -@if (Model.Content != null) -{ - // We don't render anything from custom parts in the terms list - // This can be customized per part by creating custom templates - - @*
- @await DisplayAsync(Model.Content) -
*@ -} diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TaxonomyPart.Empty.cshtml b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TaxonomyPart.Empty.cshtml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TaxonomyPart.cshtml b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TaxonomyPart.cshtml new file mode 100644 index 00000000000..1b8c3d56bf5 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TaxonomyPart.cshtml @@ -0,0 +1,8 @@ +@model TaxonomyPartViewModel +@using OrchardCore.Mvc.Utilities; +@{ + var name = ( "taxonomy-" + Model.TaxonomyPart.TermContentType).HtmlClassify(); +} +
+ +
diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Term.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Term.Edit.cshtml deleted file mode 100644 index e7d88a86000..00000000000 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Term.Edit.cshtml +++ /dev/null @@ -1,41 +0,0 @@ - - -
-
-
- @if (Model.Content != null) - { -
- @await DisplayAsync(Model.Content) -
- } -
-
- @if (Model.Parts != null) - { - @await DisplayAsync(Model.Parts) - } -
- -
- @if (Model.Actions != null) - { -
- @await DisplayAsync(Model.Actions) -
- } -
-
-@if (Model.Sidebar != null) -{ -
-
- @await DisplayAsync(Model.Sidebar) -
-} -
- -@if (!String.IsNullOrWhiteSpace(Context.Request.Query["returnUrl"])) -{ - @Html.Hidden("returnUrl", Context.Request.Query["returnUrl"]) -} diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Term.cshtml b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Term.cshtml index 9b58666655a..be9e8520193 100644 --- a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Term.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/Term.cshtml @@ -1,23 +1,16 @@ -@{ - TagBuilder tag = Tag(Model, "li"); +@model dynamic +@if ((bool)Model.HasItems) +{ + TagBuilder tag = Tag(Model, "ul"); - // Morphing the shape to keep Model untouched - Model.Metadata.Alternates.Clear(); - Model.Metadata.Type = "MenuItemLink"; - - tag.InnerHtml.AppendHtml(await DisplayAsync(Model)); - - if ((bool)Model.HasItems) + foreach (var item in Model.Items) { - tag.InnerHtml.AppendHtml("
    "); - - foreach (var item in Model.Items) - { - tag.InnerHtml.AppendHtml(await DisplayAsync(item)); - } - - tag.InnerHtml.AppendHtml("
"); + tag.InnerHtml.AppendHtml(await DisplayAsync(item)); } -} -@tag + @tag +} +else +{ +

@T["The list is empty"]

+} \ No newline at end of file diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermContentItem.cshtml b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermContentItem.cshtml new file mode 100644 index 00000000000..81f90383851 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermContentItem.cshtml @@ -0,0 +1,3 @@ +@model dynamic + +@await Orchard.DisplayAsync(Model.TermContentItem as ContentItem, "Summary") diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermItem.cshtml b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermItem.cshtml new file mode 100644 index 00000000000..f006f14791a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermItem.cshtml @@ -0,0 +1,18 @@ +@model dynamic +@{ + // Morphing the shape to keep Model untouched + Model.Metadata.Alternates.Clear(); + Model.Metadata.Type = "TermContentItem"; +} +
  • + @await DisplayAsync(Model) + @if ((bool)Model.HasItems) + { +
      + @foreach (var item in Model.Items) + { + @await DisplayAsync(item) + } +
    + } +
  • diff --git a/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermPart.cshtml b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermPart.cshtml new file mode 100644 index 00000000000..54bc1e57689 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Taxonomies/Views/TermPart.cshtml @@ -0,0 +1,25 @@ +@model TermPartViewModel + +@if (Model.ContentItems.Any()) +{ +
      + @foreach (var contentItem in Model.ContentItems) + { + var contentItemSummary = await Orchard.DisplayAsync(contentItem, "Summary"); + +
    • + @await DisplayAsync(contentItemSummary) +
    • + } +
    +} +else +{ +

    @T["The list is empty"]

    +} + +@await DisplayAsync(Model.Pager) + +@* To render the hierachy of inherited terms use the Term shape here + +*@ diff --git a/src/OrchardCore.Themes/TheBlogTheme/Recipes/blog.recipe.json b/src/OrchardCore.Themes/TheBlogTheme/Recipes/blog.recipe.json index 40d477e64d2..4db6c55f03f 100644 --- a/src/OrchardCore.Themes/TheBlogTheme/Recipes/blog.recipe.json +++ b/src/OrchardCore.Themes/TheBlogTheme/Recipes/blog.recipe.json @@ -603,7 +603,21 @@ "Position": "0" } } - } + }, + { + "PartName": "AutoroutePart", + "Name": "AutoroutePart", + "Settings": { + "ContentTypePartSettings": { + "Position": "2" + }, + "AutoroutePartSettings": { + "AllowCustomPath": true, + "Pattern": "{{ Model.ContentItem | display_text | slugify }}", + "ManageContainedItemRoutes": true + } + } + } ] }, { @@ -631,6 +645,20 @@ "Position": "0" } } + }, + { + "PartName": "AutoroutePart", + "Name": "AutoroutePart", + "Settings": { + "ContentTypePartSettings": { + "Position": "2" + }, + "AutoroutePartSettings": { + "AllowCustomPath": true, + "Pattern": "{{ Model.ContentItem | display_text | slugify }}", + "ManageContainedItemRoutes": true + } + } } ] } @@ -699,7 +727,7 @@ "Multiple": false } } - }, + }, { "FieldName": "TaxonomyField", "Name": "Tags", @@ -993,6 +1021,12 @@ "DisplayText": "Earth", "TitlePart": { "Title": "Earth" + }, + "AutoroutePart": { + "Path": "earth" + }, + "TermPart": { + "TaxonomyContentItemId": "[js: variables('tagsContentItemId')]" } }, { @@ -1001,6 +1035,12 @@ "DisplayText": "Exploration", "TitlePart": { "Title": "Exploration" + }, + "AutoroutePart": { + "Path": "exploration" + }, + "TermPart": { + "TaxonomyContentItemId": "[js: variables('tagsContentItemId')]" } }, { @@ -1009,10 +1049,20 @@ "DisplayText": "Space", "TitlePart": { "Title": "Space" + }, + "AutoroutePart": { + "Path": "space" + }, + "TermPart": { + "TaxonomyContentItemId": "[js: variables('tagsContentItemId')]" } } ], "TermContentType": "Tag" + }, + "AutoroutePart": { + "Path": "tags", + "RouteContainedItems": true } }, { @@ -1040,10 +1090,20 @@ }, "TitlePart": { "Title": "Travel" + }, + "AutoroutePart": { + "Path": "travel" + }, + "TermPart": { + "TaxonomyContentItemId": "[js: variables('categoriesContentItemId')]" } } ], "TermContentType": "Category" + }, + "AutoroutePart": { + "Path": "categories", + "RouteContainedItems": true } }, { diff --git a/src/OrchardCore.Themes/TheBlogTheme/Views/BlogPost-Category.liquid b/src/OrchardCore.Themes/TheBlogTheme/Views/BlogPost-Category.liquid index 627eadb2070..a2cec80cee1 100644 --- a/src/OrchardCore.Themes/TheBlogTheme/Views/BlogPost-Category.liquid +++ b/src/OrchardCore.Themes/TheBlogTheme/Views/BlogPost-Category.liquid @@ -1,11 +1,13 @@ {% assign name = Model.PartFieldDefinition.PartDefinition.Name | append: '-' | append: Model.PartFieldDefinition.Name | html_class %} -
    +
    {% assign categoryTerms = Model.Field | taxonomy_terms %} {% for categoryTerm in categoryTerms %} - - - {{ categoryTerm.DisplayText }} - + {% a display_for: categoryTerm %} + + + {{ categoryTerm.DisplayText }} + + {% enda %} {% endfor %}
    diff --git a/src/OrchardCore.Themes/TheBlogTheme/Views/BlogPost-Tags-TaxonomyField-Tags.Display.liquid b/src/OrchardCore.Themes/TheBlogTheme/Views/BlogPost-Tags-TaxonomyField-Tags.Display.liquid index bec6a895406..ace7aee8f95 100644 --- a/src/OrchardCore.Themes/TheBlogTheme/Views/BlogPost-Tags-TaxonomyField-Tags.Display.liquid +++ b/src/OrchardCore.Themes/TheBlogTheme/Views/BlogPost-Tags-TaxonomyField-Tags.Display.liquid @@ -1,10 +1,13 @@ {% assign name = Model.PartFieldDefinition.PartDefinition.Name | append: '-' | append: Model.PartFieldDefinition.Name | html_class %} -
    - {% for tagName in Model.TagNames %} - - - {{ tagName }} - +
    + {% assign tagTerms = Model.Field | taxonomy_terms %} + {% for tagTerm in tagTerms %} + {% a display_for: tagTerm %} + + + {{ tagTerm }} + + {% enda %} {% endfor %}
    diff --git a/src/OrchardCore.Themes/TheBlogTheme/Views/Category-TermPart.liquid b/src/OrchardCore.Themes/TheBlogTheme/Views/Category-TermPart.liquid new file mode 100644 index 00000000000..68c1a1d02fe --- /dev/null +++ b/src/OrchardCore.Themes/TheBlogTheme/Views/Category-TermPart.liquid @@ -0,0 +1,13 @@ +{% for item in Model.ContentItems %} + {{ item | shape_build_display: "Summary" | shape_render }} +{% endfor %} + +{% assign previousText = "← Newer Posts" | t %} +{% assign nextText = "Older Posts →" | t %} +{% assign previousClass = "previous" | t %} +{% assign nextClass = "next" | t %} + +{% shape_pager Model.Pager previous_text: previousText, next_text: nextText, + previous_class: previousClass, next_class: nextClass %} + +{{ Model.Pager | shape_render }} diff --git a/src/OrchardCore.Themes/TheBlogTheme/Views/Content-BlogPost.Summary.liquid b/src/OrchardCore.Themes/TheBlogTheme/Views/Content-BlogPost.Summary.liquid index 48ee01e9f42..ea7860c71fe 100644 --- a/src/OrchardCore.Themes/TheBlogTheme/Views/Content-BlogPost.Summary.liquid +++ b/src/OrchardCore.Themes/TheBlogTheme/Views/Content-BlogPost.Summary.liquid @@ -12,21 +12,27 @@ {% assign dateTime = "DateTime" | shape_new: utc: Model.ContentItem.CreatedUtc, format: format | shape_stringify %} {{ "Posted by" | t }} {{ Model.ContentItem.Owner }} {{ "on {0}" | t: dateTime | raw }} - {% assign categoryTerms = Model.ContentItem.Content.BlogPost.Category | taxonomy_terms %} - {% for categoryTerm in categoryTerms %} - - - {{ categoryTerm.DisplayText }} - - {% endfor %} - - {% for tagName in Model.ContentItem.Content.BlogPost.Tags.TagNames %} - - - {{ tagName }} - - {% endfor %} + + {% assign categoryTerms = Model.ContentItem.Content.BlogPost.Category | taxonomy_terms %} + {% for categoryTerm in categoryTerms %} + {% a display_for:categoryTerm %} + + + {{ categoryTerm.DisplayText }} + + {% enda %} + {% endfor %} + {% assign tagTerms = Model.ContentItem.Content.BlogPost.Tags | taxonomy_terms %} + {% for tagTerm in tagTerms %} + {% a display_for:tagTerm %} + + + {{ tagTerm.DisplayText }} + + {% enda %} + {% endfor %} +


    diff --git a/src/OrchardCore.Themes/TheBlogTheme/Views/Content-Category.Summary.liquid b/src/OrchardCore.Themes/TheBlogTheme/Views/Content-Category.Summary.liquid new file mode 100644 index 00000000000..e6674c43a86 --- /dev/null +++ b/src/OrchardCore.Themes/TheBlogTheme/Views/Content-Category.Summary.liquid @@ -0,0 +1,8 @@ +
    + {% a display_for:Model.ContentItem %} +

    + + {{ Model.ContentItem.DisplayText }} +

    + {% enda %} +
    \ No newline at end of file diff --git a/src/OrchardCore.Themes/TheBlogTheme/Views/Content-Category.liquid b/src/OrchardCore.Themes/TheBlogTheme/Views/Content-Category.liquid new file mode 100644 index 00000000000..9628b6499a6 --- /dev/null +++ b/src/OrchardCore.Themes/TheBlogTheme/Views/Content-Category.liquid @@ -0,0 +1,21 @@ +{% zone "Header" %} + + +
    +
    +
    +
    +
    +
    +

    + + {{ Model.ContentItem.DisplayText }} +

    +
    +
    +
    +
    +
    +{% endzone %} + +{{ Model.Content.TermPart | shape_render }} \ No newline at end of file diff --git a/src/OrchardCore.Themes/TheBlogTheme/Views/Tag-TermPart.liquid b/src/OrchardCore.Themes/TheBlogTheme/Views/Tag-TermPart.liquid new file mode 100644 index 00000000000..68c1a1d02fe --- /dev/null +++ b/src/OrchardCore.Themes/TheBlogTheme/Views/Tag-TermPart.liquid @@ -0,0 +1,13 @@ +{% for item in Model.ContentItems %} + {{ item | shape_build_display: "Summary" | shape_render }} +{% endfor %} + +{% assign previousText = "← Newer Posts" | t %} +{% assign nextText = "Older Posts →" | t %} +{% assign previousClass = "previous" | t %} +{% assign nextClass = "next" | t %} + +{% shape_pager Model.Pager previous_text: previousText, next_text: nextText, + previous_class: previousClass, next_class: nextClass %} + +{{ Model.Pager | shape_render }} diff --git a/src/OrchardCore.Themes/TheBlogTheme/Views/Term-Category.liquid b/src/OrchardCore.Themes/TheBlogTheme/Views/Term-Category.liquid new file mode 100644 index 00000000000..fb3a4b19450 --- /dev/null +++ b/src/OrchardCore.Themes/TheBlogTheme/Views/Term-Category.liquid @@ -0,0 +1,6 @@ +
      + {% for item in Model.Items %} + {% shape_add_classes item "list-group-item border-0 pb-0" %} + {{ item | shape_render }} + {% endfor %} +
    diff --git a/src/OrchardCore.Themes/TheBlogTheme/Views/TermContentItem-Tag.liquid b/src/OrchardCore.Themes/TheBlogTheme/Views/TermContentItem-Tag.liquid new file mode 100644 index 00000000000..5bb4e33a0c2 --- /dev/null +++ b/src/OrchardCore.Themes/TheBlogTheme/Views/TermContentItem-Tag.liquid @@ -0,0 +1,7 @@ +
    + {% a display_for:Model.TermContentItem %} +

    + {{ Model.TermContentItem.DisplayText }} +

    + {% enda %} +
    \ No newline at end of file diff --git a/src/OrchardCore.Themes/TheBlogTheme/Views/TermItem-Category.liquid b/src/OrchardCore.Themes/TheBlogTheme/Views/TermItem-Category.liquid new file mode 100644 index 00000000000..95f75b1e6db --- /dev/null +++ b/src/OrchardCore.Themes/TheBlogTheme/Views/TermItem-Category.liquid @@ -0,0 +1,13 @@ +
  • + {% shape_clear_alternates Model %} + {% shape_type Model "TermContentItem" %} + {{ Model | shape_render }} + {% if Model.HasItems %} +
      + {% for item in Model.Items %} + {% shape_add_classes item "list-group-item border-0 pb-0" %} + {{ item | shape_render }} + {% endfor %} +
    + {% endif %} +
  • \ No newline at end of file diff --git a/src/OrchardCore.Themes/TheBlogTheme/wwwroot/css/bootstrap-oc.css b/src/OrchardCore.Themes/TheBlogTheme/wwwroot/css/bootstrap-oc.css index c93edb665d5..70531e4142e 100644 --- a/src/OrchardCore.Themes/TheBlogTheme/wwwroot/css/bootstrap-oc.css +++ b/src/OrchardCore.Themes/TheBlogTheme/wwwroot/css/bootstrap-oc.css @@ -24,31 +24,31 @@ By Orchard Core Team } .message-primary { - color: #004085; - background-color: #cce5ff; - border-color: #b8daff; + color: #004554; + background-color: #cce7ec; + border-color: #b8dde5; } .message-primary hr { - border-top-color: #9fcdff; + border-top-color: #a5d4de; } .message-primary .alert-link { - color: #002752; + color: #001b21; } .message-secondary { - color: #383d41; - background-color: #e2e3e5; - border-color: #d6d8db; + color: #464a4e; + background-color: #e7e8ea; + border-color: #dddfe2; } .message-secondary hr { - border-top-color: #c8cbcf; + border-top-color: #cfd2d6; } .message-secondary .alert-link { - color: #202326; + color: #2e3133; } .message-success { @@ -255,14 +255,14 @@ ul.pager li a { padding: 0.5rem 0.75rem; margin-left: -1px; line-height: 1.25; - color: #007bff; + color: #0085A1; background-color: #fff; border: 1px solid #dee2e6; } ul.pager li a:hover { z-index: 2; - color: #0056b3; + color: #004655; text-decoration: none; background-color: #e9ecef; border-color: #dee2e6; @@ -271,7 +271,7 @@ ul.pager li a:hover { ul.pager li a:focus { z-index: 2; outline: 0; - box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + box-shadow: 0 0 0 0.2rem rgba(0, 133, 161, 0.25); } ul.pager li:first-child .page-link { @@ -288,12 +288,12 @@ ul.pager li:last-child .page-link { ul.pager li.active .page-link { z-index: 1; color: #fff; - background-color: #007bff; - border-color: #007bff; + background-color: #0085A1; + border-color: #0085A1; } ul.pager li.disabled .page-link { - color: #6c757d; + color: #868e96; pointer-events: none; cursor: auto; background-color: #fff; @@ -303,3 +303,16 @@ ul.pager li.disabled .page-link { ul.pager { margin-top: 1rem; } + +.term-badge > a { + text-decoration: none; +} + +.term-badge .badge-secondary:hover { + background-color: #0085A1; +} + +.taxonomy-tag ul { + padding-left: 0; + list-style: none; +} diff --git a/src/OrchardCore.Themes/TheBlogTheme/wwwroot/css/bootstrap-oc.min.css b/src/OrchardCore.Themes/TheBlogTheme/wwwroot/css/bootstrap-oc.min.css index 065cd391da8..3cdd727bdc3 100644 --- a/src/OrchardCore.Themes/TheBlogTheme/wwwroot/css/bootstrap-oc.min.css +++ b/src/OrchardCore.Themes/TheBlogTheme/wwwroot/css/bootstrap-oc.min.css @@ -2,4 +2,4 @@ * Start Bootstrap - Clean Blog v5.0.8 (https://startbootstrap.com/template-overviews/clean-blog) * Copyright 2013-2020 Start Bootstrap * Licensed under MIT (https://github.com/BlackrockDigital/startbootstrap-clean-blog/blob/master/LICENSE) - */.flow{display:flex;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.message{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.message-primary{color:#004085;background-color:#cce5ff;border-color:#b8daff}.message-primary hr{border-top-color:#9fcdff}.message-primary .alert-link{color:#002752}.message-secondary{color:#383d41;background-color:#e2e3e5;border-color:#d6d8db}.message-secondary hr{border-top-color:#c8cbcf}.message-secondary .alert-link{color:#202326}.message-success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.message-success hr{border-top-color:#b1dfbb}.message-success .alert-link{color:#0b2e13}.message-info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.message-info hr{border-top-color:#abdde5}.message-info .alert-link{color:#062c33}.message-warning{color:#856404;background-color:#fff3cd;border-color:#ffeeba}.message-warning hr{border-top-color:#ffe8a1}.message-warning .alert-link{color:#533f03}.message-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.message-danger hr{border-top-color:#f1b0b7}.message-danger .alert-link{color:#491217}.message-light{color:#818182;background-color:#fefefe;border-color:#fdfdfe}.message-light hr{border-top-color:#ececf6}.message-light .alert-link{color:#686868}.message-dark{color:#1b1e21;background-color:#d6d8d9;border-color:#c6c8ca}.message-dark hr{border-top-color:#b9bbbe}.message-dark .alert-link{color:#040505}.widget-container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.widget-image-widget img{width:100%}.widget-align-left{text-align:left}.widget-align-center{text-align:center}.widget-align-right{text-align:right}.widget-align-justify{text-align:justify}.widget .widget-size-25,.widget .widget-size-33,.widget .widget-size-50,.widget .widget-size-66,.widget .widget-size-75,.widget-size-100,.widget-size-25,.widget-size-33,.widget-size-50,.widget-size-66,.widget-size-75{position:relative;width:100%;padding-right:15px;padding-left:15px;flex:0 0 100%;max-width:100%}@media (min-width:576px){.widget .widget-size-25,.widget-size-25{flex:0 0 25%;max-width:25%}}@media (min-width:768px){.widget .widget-size-25{flex:0 0 25%;max-width:25%}}@media (min-width:576px){.widget .widget-size-33,.widget-size-33{flex:0 0 33%;max-width:33%}}@media (min-width:768px){.widget .widget-size-33{flex:0 0 33%;max-width:33%}}@media (min-width:576px){.widget .widget-size-50,.widget-size-50{flex:0 0 50%;max-width:50%}}@media (min-width:768px){.widget .widget-size-50{flex:0 0 50%;max-width:50%}}@media (min-width:576px){.widget .widget-size-66,.widget-size-66{flex:0 0 66%;max-width:66%}}@media (min-width:768px){.widget .widget-size-66{flex:0 0 66%;max-width:66%}}@media (min-width:576px){.widget .widget-size-75,.widget-size-75{flex:0 0 75%;max-width:75%}}@media (min-width:768px){.widget .widget-size-75{flex:0 0 75%;max-width:75%}}ul.pager{display:flex;padding-left:0;list-style:none;border-radius:.25rem}ul.pager li a{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#007bff;background-color:#fff;border:1px solid #dee2e6}ul.pager li a:hover{z-index:2;color:#0056b3;text-decoration:none;background-color:#e9ecef;border-color:#dee2e6}ul.pager li a:focus{z-index:2;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}ul.pager li:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}ul.pager li:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}ul.pager li.active .page-link{z-index:1;color:#fff;background-color:#007bff;border-color:#007bff}ul.pager li.disabled .page-link{color:#6c757d;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dee2e6}ul.pager{margin-top:1rem} \ No newline at end of file + */.flow{display:flex;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.message{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.message-primary{color:#004554;background-color:#cce7ec;border-color:#b8dde5}.message-primary hr{border-top-color:#a5d4de}.message-primary .alert-link{color:#001b21}.message-secondary{color:#464a4e;background-color:#e7e8ea;border-color:#dddfe2}.message-secondary hr{border-top-color:#cfd2d6}.message-secondary .alert-link{color:#2e3133}.message-success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.message-success hr{border-top-color:#b1dfbb}.message-success .alert-link{color:#0b2e13}.message-info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.message-info hr{border-top-color:#abdde5}.message-info .alert-link{color:#062c33}.message-warning{color:#856404;background-color:#fff3cd;border-color:#ffeeba}.message-warning hr{border-top-color:#ffe8a1}.message-warning .alert-link{color:#533f03}.message-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.message-danger hr{border-top-color:#f1b0b7}.message-danger .alert-link{color:#491217}.message-light{color:#818182;background-color:#fefefe;border-color:#fdfdfe}.message-light hr{border-top-color:#ececf6}.message-light .alert-link{color:#686868}.message-dark{color:#1b1e21;background-color:#d6d8d9;border-color:#c6c8ca}.message-dark hr{border-top-color:#b9bbbe}.message-dark .alert-link{color:#040505}.widget-container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.widget-image-widget img{width:100%}.widget-align-left{text-align:left}.widget-align-center{text-align:center}.widget-align-right{text-align:right}.widget-align-justify{text-align:justify}.widget .widget-size-25,.widget .widget-size-33,.widget .widget-size-50,.widget .widget-size-66,.widget .widget-size-75,.widget-size-100,.widget-size-25,.widget-size-33,.widget-size-50,.widget-size-66,.widget-size-75{position:relative;width:100%;padding-right:15px;padding-left:15px;flex:0 0 100%;max-width:100%}@media (min-width:576px){.widget .widget-size-25,.widget-size-25{flex:0 0 25%;max-width:25%}}@media (min-width:768px){.widget .widget-size-25{flex:0 0 25%;max-width:25%}}@media (min-width:576px){.widget .widget-size-33,.widget-size-33{flex:0 0 33%;max-width:33%}}@media (min-width:768px){.widget .widget-size-33{flex:0 0 33%;max-width:33%}}@media (min-width:576px){.widget .widget-size-50,.widget-size-50{flex:0 0 50%;max-width:50%}}@media (min-width:768px){.widget .widget-size-50{flex:0 0 50%;max-width:50%}}@media (min-width:576px){.widget .widget-size-66,.widget-size-66{flex:0 0 66%;max-width:66%}}@media (min-width:768px){.widget .widget-size-66{flex:0 0 66%;max-width:66%}}@media (min-width:576px){.widget .widget-size-75,.widget-size-75{flex:0 0 75%;max-width:75%}}@media (min-width:768px){.widget .widget-size-75{flex:0 0 75%;max-width:75%}}ul.pager{display:flex;padding-left:0;list-style:none;border-radius:.25rem}ul.pager li a{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#0085a1;background-color:#fff;border:1px solid #dee2e6}ul.pager li a:hover{z-index:2;color:#004655;text-decoration:none;background-color:#e9ecef;border-color:#dee2e6}ul.pager li a:focus{z-index:2;outline:0;box-shadow:0 0 0 .2rem rgba(0,133,161,.25)}ul.pager li:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}ul.pager li:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}ul.pager li.active .page-link{z-index:1;color:#fff;background-color:#0085a1;border-color:#0085a1}ul.pager li.disabled .page-link{color:#868e96;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dee2e6}ul.pager{margin-top:1rem}.term-badge>a{text-decoration:none}.term-badge .badge-secondary:hover{background-color:#0085a1}.taxonomy-tag ul{padding-left:0;list-style:none} \ No newline at end of file diff --git a/src/OrchardCore.Themes/TheBlogTheme/wwwroot/scss/bootstrap-oc.scss b/src/OrchardCore.Themes/TheBlogTheme/wwwroot/scss/bootstrap-oc.scss index 8a3ee0242c7..86332733549 100644 --- a/src/OrchardCore.Themes/TheBlogTheme/wwwroot/scss/bootstrap-oc.scss +++ b/src/OrchardCore.Themes/TheBlogTheme/wwwroot/scss/bootstrap-oc.scss @@ -2,6 +2,7 @@ Bootstrap customization for The Blog theme By Orchard Core Team */ +@import "variables.scss"; @import "../node_modules/bootstrap/scss/functions"; @import "../node_modules/bootstrap/scss/variables"; @import "../node_modules/bootstrap/scss/mixins"; @@ -10,3 +11,4 @@ By Orchard Core Team @import "modules/_messages.scss"; @import "modules/_widgets.scss"; @import "modules/_pager.scss"; +@import "modules/_taxonomy.scss"; diff --git a/src/OrchardCore.Themes/TheBlogTheme/wwwroot/scss/modules/_taxonomy.scss b/src/OrchardCore.Themes/TheBlogTheme/wwwroot/scss/modules/_taxonomy.scss new file mode 100644 index 00000000000..54e44231fed --- /dev/null +++ b/src/OrchardCore.Themes/TheBlogTheme/wwwroot/scss/modules/_taxonomy.scss @@ -0,0 +1,18 @@ +.term-badge { + > a { + text-decoration: none; + } + + .badge-secondary { + &:hover { + background-color: $primary; + } + } +} + +.taxonomy-tag { + ul { + padding-left: 0; + list-style: none; + } +} diff --git a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/ContentManagerExtension.cs b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/ContentManagerExtension.cs index 1242477cae9..8b3c8db55d3 100644 --- a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/ContentManagerExtension.cs +++ b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/ContentManagerExtension.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using Newtonsoft.Json.Linq; namespace OrchardCore.ContentManagement { diff --git a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/IContentManager.cs b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/IContentManager.cs index 644625f8f93..6f04ba84b0d 100644 --- a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/IContentManager.cs +++ b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/IContentManager.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; namespace OrchardCore.ContentManagement { @@ -134,6 +135,38 @@ public static async Task> LoadAsync(this IContentManage return results; } + + /// + /// Gets either the published container content item with the specified id, or if the json path supplied gets the contained content item. + /// + /// The content item id to load + /// The json path of the contained content item + public static Task GetAsync(this IContentManager contentManager, string id, string jsonPath) + { + return contentManager.GetAsync(id, jsonPath, VersionOptions.Latest); + } + + /// + /// Gets either the container content item with the specified id and version, or if the json path supplied gets the contained content item. + /// + /// The id content item id to load + /// The version option + /// The json path of the contained content item + public static async Task GetAsync(this IContentManager contentManager, string id, string jsonPath, VersionOptions options) + { + var contentItem = await contentManager.GetAsync(id, options); + + // It represents a contained content item + if (!string.IsNullOrEmpty(jsonPath)) + { + var root = contentItem.Content as JObject; + contentItem = root.SelectToken(jsonPath)?.ToObject(); + + return contentItem; + } + + return contentItem; + } } public class VersionOptions diff --git a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteEntriesExtensions.cs b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteEntriesExtensions.cs index 0f948bda198..b949b48ee52 100644 --- a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteEntriesExtensions.cs +++ b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteEntriesExtensions.cs @@ -2,14 +2,14 @@ namespace OrchardCore.ContentManagement.Routing { public static class AutorouteEntriesExtensions { - public static void AddEntry(this IAutorouteEntries entries, string contentItemId, string path) + public static void AddEntry(this IAutorouteEntries entries, string contentItemId, string path, string containedContentItemId = null, string jsonPath = null) { - entries.AddEntries(new[] { new AutorouteEntry { ContentItemId = contentItemId, Path = path } }); + entries.AddEntries(new[] { new AutorouteEntry (contentItemId, path, containedContentItemId, jsonPath) }); } - public static void RemoveEntry(this IAutorouteEntries entries, string contentItemId, string path) + public static void RemoveEntry(this IAutorouteEntries entries, string contentItemId, string path, string containedContentItemId = null, string jsonPath = null) { - entries.RemoveEntries(new[] { new AutorouteEntry { ContentItemId = contentItemId, Path = path } }); + entries.RemoveEntries(new[] { new AutorouteEntry (contentItemId, path, containedContentItemId, jsonPath) }); } } } diff --git a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteEntry.cs b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteEntry.cs index 6c4b6367bcd..d3a9864c2f5 100644 --- a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteEntry.cs +++ b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteEntry.cs @@ -1,8 +1,37 @@ namespace OrchardCore.ContentManagement.Routing { - public struct AutorouteEntry + public class AutorouteEntry { + public AutorouteEntry(string contentItemId, string path, string containedContentItemId = null, string jsonPath = null) + { + ContentItemId = contentItemId; + + // Normalize path. + Path = "/" + path.Trim('/'); + + ContainedContentItemId = containedContentItemId;//; ?? contentItemId; + JsonPath = jsonPath; + } + + /// + /// The id of the database document. + /// public string ContentItemId; + + /// + /// The path of the entry. + /// public string Path; + + /// + /// The id of an item contained within the document. + /// May be null. + /// + public string ContainedContentItemId; + + /// + /// The json path of an item contained within then document. + /// + public string JsonPath; } } diff --git a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteOptions.cs b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteOptions.cs index 1fab80efb4d..305de442465 100644 --- a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteOptions.cs +++ b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/AutorouteOptions.cs @@ -6,5 +6,9 @@ public class AutorouteOptions { public RouteValueDictionary GlobalRouteValues { get; set; } = new RouteValueDictionary(); public string ContentItemIdKey { get; set; } = ""; + + // The contained content item key is only used for route generation and should be removed after generation. + public string ContainedContentItemIdKey { get; set; } = ""; + public string JsonPathKey { get; set; } = ""; } } diff --git a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/ContainedContentItemsAspect.cs b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/ContainedContentItemsAspect.cs new file mode 100644 index 00000000000..bdcba91e94a --- /dev/null +++ b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/ContainedContentItemsAspect.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace OrchardCore.ContentManagement.Routing +{ + public class ContainedContentItemsAspect + { + /// + /// Json accessors to provide a list of contained content items. + /// + public IList> Accessors { get; set; } = new List>(); + } +} diff --git a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/IAutorouteEntries.cs b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/IAutorouteEntries.cs index bd7d7e00ed2..51291a6c6c9 100644 --- a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/IAutorouteEntries.cs +++ b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/IAutorouteEntries.cs @@ -4,8 +4,8 @@ namespace OrchardCore.ContentManagement.Routing { public interface IAutorouteEntries { - bool TryGetContentItemId(string path, out string contentItemId); - bool TryGetPath(string contentItemId, out string path); + bool TryGetEntryByPath(string path, out AutorouteEntry entry); + bool TryGetEntryByContentItemId(string contentItemId, out AutorouteEntry entry); void AddEntries(IEnumerable entries); void RemoveEntries(IEnumerable entries); } diff --git a/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/RouteHandlerAspect.cs b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/RouteHandlerAspect.cs new file mode 100644 index 00000000000..184fa00228c --- /dev/null +++ b/src/OrchardCore/OrchardCore.ContentManagement.Abstractions/Routing/RouteHandlerAspect.cs @@ -0,0 +1,20 @@ +namespace OrchardCore.ContentManagement.Routing +{ + public class RouteHandlerAspect + { + /// + /// Route path. + /// + public string Path { get; set; } + + /// + /// Whether route is absolute to the parent content item route. + /// + public bool Absolute { get; set; } + + /// + /// Whether this contained content item can be routed to. + /// + public bool Disabled { get; set; } + } +} diff --git a/src/docs/reference/modules/Autoroute/README.md b/src/docs/reference/modules/Autoroute/README.md index 2a8f31b6738..e2d254d63e3 100644 --- a/src/docs/reference/modules/Autoroute/README.md +++ b/src/docs/reference/modules/Autoroute/README.md @@ -51,3 +51,108 @@ or ```liquid {% assign my_content = Content.Slug["my-blog/my-blog-post"] %} ``` + +## Container Routing + +The `AutoroutePart` supports routing of content items which are children of a parent content item. + +### Container and Contained Definitions + +### Container Definition + +A _container_ content item is a parent content item, which _contains_ child content items. + +For example : + +- A content item with a `BagPart` attached is a _container or parent_ content item. + +- A `Taxonomy` is also a _container_ content item. + +### Contained Definition + +A _contained_ content item refers to a content item which is _contained_ inside a _container_ content item. + +For example : + +- Content items _contained_ inside a `BagPart` are considered _contained or child_ content items. + +- `Terms` of a `Taxonomy` of a taxonomy are _contained_ by the `Taxonomy`. + +_Contained_ content items are stored as part of the json inside the _container_ document. + +### Supported Containers + +The `AutoroutePart` supports routing of these _container_ types. + +- `BagPart` and content items _contained_ by the `BagPart` +- `Taxonomy` content items and _contained_ `Terms` + +### Configuration + +To enabled routing of _contained_ content items the `AutoroutePart` must be configured correctly. + +- Add the `AutoroutePart` to the _container or parent_ content type definition. +- Enable `Allow Route Contained Items` on the `AutoroutePart Settings`. +- Enable `Route Contained Items` on the _container_ content item. + +Optionally, add the `AutoroutePart` to the content type definition of the _contained or child_ content items, to manage the contained item routes. + +#### Path Generation + +By default when the `AutoroutePart` is added to a _container_ content item and `Route Contained Items` is enabled the generated route will be made up of the _container_ segment, then the `ContentItemId` of the _contained_ content item, and if present, the `DisplayText`. + +For example : +`https://www.mysite.com/categories/47twnxzx9hs5k3dyn9j1mc5rny-travel` + +To configure a friendly slug for the child content items add the `AutoroutePart` to the content type definition for those content types. + +You are then able to use the `Liquid` pattern to generate a friendly slug. + +For example : +`https://www.mysite.com/categories/travel` + +Routing is, by default, relative to the parent, and there is no `Liquid` filter for the parent as there is with the `ListPart` + +#### AutoroutePart Settings + +Settings on the `AutoroutePart` allow the site administrator to control how _container and contained_ routing is enabled for a user. + +##### Allow Route Contained Items + +Enable this on a `container` content type definition, i.e. the parent, to allow a user to turn on routing for _contained_, i.e. child, content items. + +##### Manage Contained Item Routes + +Enable this on a `contained` content type definition, i.e. the child, to allow the `AutoroutePart` to configure individual control of routing for these content items. + +##### Allow Absolute Path + +Enabled `AllowAbsolutePath` to allow a user to set a path as absolute. + +By default _container_ routing will build a url relative to the _containers_ route. + +##### Allow Disabled + +Enable this option to allow content editors to disable route generation. + +Use this option when you have a _container_ with two `BagParts` and routing should only be enabled for one. + +#### AutoroutePart Content Item Editor + +##### Disabled + +Content editors can select to disabled route generation for a particular _contained_ content item. + +##### Route Contained Items + +When editing the content item select `Route Contained Items` on the _container_ content item to turn on routing for the _contained_ content items. + +##### Absolute + +When selected forces the route from relative to absolute. + +!!! note + When you configure _container routing_ for a `BagPart` you will want to change the default display type of the part to `Summary` and create `Summary` and `Detail` templates, for use when the item is displayed within its _container_ and when it is displayed in detail via a route. + + + diff --git a/src/docs/reference/modules/Taxonomies/README.md b/src/docs/reference/modules/Taxonomies/README.md index 97733eebfb2..75da78dc755 100644 --- a/src/docs/reference/modules/Taxonomies/README.md +++ b/src/docs/reference/modules/Taxonomies/README.md @@ -6,6 +6,198 @@ to be associated with one or many terms of a taxonomy. ## Shapes +### TaxonomyPart + +Display for a taxonomy is routable by enabling `Container routing` feature on the `AutoroutePart` settings for the `Taxonomy`. + +The display for the `Taxonomy` is then rendered by the `TaxonomyPart` shape. + +This uses the `TermShape` to display a heirachy of the `Terms` + +### TermPart + +The `TermPart` is rendered when a `Term` is displayed with the `Container routing` feature of the `AutoroutePart`. + +It renders a list of all content items that have been categorized by the `TaxonomyField` as part of that `Term` hierarchy. + +### Term Shape + +The `TermShape` is used by the `TaxonomyPart` display to render the list of term hierarchies for the `Taxonomy`. + +It is a reusable shape which may also be called from a content item, similar to that of a `MenuShape`, to render either the entire `Taxonomy` and term hierarchy, or a part of the `Term` hierarchy. + +You might invoke the `TermShape` from a content template to render a sidebar of associated taxonomy terms. + +``` liquid tab="Liquid" +{% shape "term", alias: "alias:categories" %} +``` + +``` html tab="Razor" + +``` + +You can also specify a `TermContentItemId` to render a part of the term hierarchy. + +``` liquid tab="Liquid" +{% shape "term", TaxonomyContentItemId: "taxonomyContentItemId" TermContentItemId: "termContentItemId" %} +``` + +``` html tab="Razor" + +``` + +| Property | Description | +| --------- | ------------ | +| `Model.TaxonomyContentItemId` | If defined, contains the content item identifier of the taxonomy to render. | +| `Model.Items` | The list of term items shapes for the taxonomy. These are shapes of type `TermItem`. | +| `Model.Differentiator` | If defined, contains the formatted name of the taxonomy (display text). For instance `Categories`. | +| `Model.TermContentItemId` | If defined, contains the content item identifier of the term to start rendering the hierarchy. | + +#### Term Alternates + +| Definition | Template | Filename| +| ---------- | --------- | ------------ | +| `Term__[Differentiator]` | `Term__Categories` | `Term-Categories.cshtml` | +| `Term__[ContentType]` | `Term__Category` | `Term-Category.cshtml` | + +#### Term Examples + +``` liquid tab="Liquid" +
      + {% for item in Model.Items %} + {% shape_add_classes item "list-group-item border-0 pb-0" %} + {{ item | shape_render }} + {% endfor %} +
    +``` + +``` html tab="Razor" +@model dynamic +@if ((bool)Model.HasItems) +{ + TagBuilder tag = Tag(Model, "ul"); + + foreach (var item in Model.Items) + { + tag.InnerHtml.AppendHtml(await DisplayAsync(item)); + } + + @tag +} +else +{ +

    @T["The list is empty"]

    +} +@tag +``` + +### TermItem + +The `TermItem` shape is used to render a term item. + +| Property | Description | +| --------- | ------------ | +| `Model.Term` | The `Term` shape owning this item. | +| `Model.TaxonomyContentItem` | The `TaxonomyContentItem`. | +| `Model.TermContentItem` | The `TermContentItem` for this item. | +| `Model.Level` | The level of the term item. `0` for top level term items. | +| `Model.Items` | The list of sub term items shapes. These are shapes of type `TermItem`. | +| `Model.Terms` | The list of term content items for the lower items in the hierarchy. | +| `Model.Differentiator` | If defined, contains the formatted name of the taxonomy. For instance `Categories`. | + +!!! note + When rendering a partial hierarchy of terms using the `TermContentItemId` property, the level is always based of the taxonomy root. + +#### TermItem Alternates + +| Definition | Template | Filename| +| ---------- | --------- | ------------ | +| `TermItem__level__[level]` | `TermItem__level__2` | `TermItem-level-2.cshtml` | +| `TermItem__[ContentType]` | `TermItem__Category` | `TermItem-Category.cshtml` | +| `TermItem__[ContentType]__level__[level]` | `TermItem__Category__level__2` | `TermItem-Category-level-2.cshtml` | +| `TermItem__[Differentiator]` | `TermItem__Categories` | `TermItem-Categories.cshtml` | +| `TermItem__[Differentiator]__level__[level]` | `TermItem__Categories__level__2` | `TermItem-Categories-level-2.cshtml` | +| `TermItem__[Differentiator]__[ContentType]` | `TermItem__Categories__Category` | `TermItem-Categories-Category.cshtml` | +| `TermItem__[Differentiator]__[ContentType]__level__[level]` | `TermItem__Categories__ContentType__level__2` | `TermItem-Categories-Category-level-2.cshtml` | + +#### TermItem Example + +``` liquid tab="Liquid" +
  • + {% shape_clear_alternates Model %} + {% shape_type Model "TermContentItem" %} + {{ Model | shape_render }} + {% if Model.HasItems %} +
      + {% for item in Model.Items %} + {% shape_add_classes item "list-group-item border-0 pb-0" %} + {{ item | shape_render }} + {% endfor %} +
    + {% endif %} +
  • +``` + +``` html tab="Razor" +@model dynamic +@{ + // Morphing the shape to keep Model untouched + Model.Metadata.Alternates.Clear(); + Model.Metadata.Type = "TermContentItem"; +} +
  • + @await DisplayAsync(Model) + @if ((bool)Model.HasItems) + { +
      + @foreach (var item in Model.Items) + { + @await DisplayAsync(item) + } +
    + } +
  • +``` +### TermContentItem + +The `TermContentItem` shape is used to render the term content item. +This shape is created by morphing a `TermItem` shape into a `TermContentItem`. Hence all the properties +available on the `TermItem` shape are still available. + +| Property | Description | +| --------- | ------------ | +| `Model.Term` | The `Term` shape owning this item. | +| `Model.TaxonomyContentItem` | The `TaxonomyContentItem`. | +| `Model.TermContentItem` | The `TermContentItem` for this item. | +| `Model.Level` | The level of the term item. `0` for top level term items. | +| `Model.Items` | The list of sub term items shapes. These are shapes of type `TermItem`. | +| `Model.Terms` | The list of term content items for the lower items in the hierarchy. | +| `Model.Differentiator` | If defined, contains the formatted name of the term. For instance `Travel`. | + +#### TermContentItem Alternates + +| Definition | Template | Filename| +| ---------- | --------- | ------------ | +| `TermContentItem__level__[level]` | `TermContentItem__level__2` | `TermContentItem-level-2.cshtml` | +| `TermContentItem__[ContentType]` | `TermContentItem__Category` | `TermContentItem-Category.cshtml` | +| `TermContentItem__[ContentType]__level__[level]` | `TermContentItem__Category__level__2` | `TermContentItem-Category-level-2.cshtml` | +| `TermContentItem__[Differentiator]` | `TermContentItem__Categories` | `TermContentItem-Categories.cshtml` | +| `TermContentItem__[Differentiator]__level__[level]` | `TermContentItem__Categories__level__2` | `TermContentItem-Categories-level-2.cshtml` | +| `TermContentItem__[Differentiator]__[ContentType]` | `TermContentItem__Categories__Category` | `TermContentItem-Categories-Category.cshtml` | +| `TermContentItem__[Differentiator]__[ContentType]__level__[level]` | `TermContentItem__Categories__Category__level__2` | `TermContentItem-Categories-Category-level-2.cshtml` | + +#### TermContentItem Example + +``` liquid tab="Liquid" +{{ Model.TermContentItem | shape_build_display: "Summary" | shape_render }} +``` + +``` html tab="Razor" +@model dynamic + +@await Orchard.DisplayAsync(Model.TermContentItem as ContentItem, "Summary") +``` + ### TaxonomyField This shape is rendered when a `TaxonomyField` is attached to a content part. diff --git a/test/OrchardCore.Tests/Routing/AutorouteEntriesTests.cs b/test/OrchardCore.Tests/Routing/AutorouteEntriesTests.cs new file mode 100644 index 00000000000..b0be8e8acb3 --- /dev/null +++ b/test/OrchardCore.Tests/Routing/AutorouteEntriesTests.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Text; +using OrchardCore.Autoroute.Services; +using OrchardCore.ContentManagement.Routing; +using Xunit; + +namespace OrchardCore.Tests.Routing +{ + public class AutorouteEntriesTests + { + [Fact] + public void ShouldGetContainedEntryByPath() + { + // Setup + var entries = new AutorouteEntries(); + + var initialEntries = new List() + { + new AutorouteEntry("container", "container-path"), + new AutorouteEntry("container", "contained-path", "contained") + }; + + entries.AddEntries(initialEntries); + + // Act + var result = entries.TryGetEntryByPath("/contained-path", out var containedEntry); + + // Test + Assert.True(result); + Assert.Equal("contained", containedEntry.ContainedContentItemId); + } + + [Fact] + public void ShouldGetEntryByContainedContentItemId() + { + // Setup + var entries = new AutorouteEntries(); + + var initialEntries = new List() + { + new AutorouteEntry("container", "container-path"), + new AutorouteEntry("container", "contained-path", "contained") + }; + + entries.AddEntries(initialEntries); + + // Act + var result = entries.TryGetEntryByContentItemId("contained", out var containedEntry); + + // Test + Assert.True(result); + Assert.Equal("/contained-path", containedEntry.Path); + } + + [Fact] + public void RemovesContainedEntriesWhenContainerRemoved() + { + // Setup + var entries = new AutorouteEntries(); + + var initialEntries = new List() + { + new AutorouteEntry("container", "container-path"), + new AutorouteEntry("container", "contained-path", "contained") + }; + + entries.AddEntries(initialEntries); + + // Act + entries.RemoveEntry("container", "container-path"); + var result = entries.TryGetEntryByPath("/contained-path", out var entry); + + // Test + Assert.False(result); + } + + [Fact] + public void RemovesContainedEntriesWhenDeleted() + { + // Setup + var entries = new AutorouteEntries(); + + var initialEntries = new List() + { + new AutorouteEntry("container", "container-path"), + new AutorouteEntry("container", "contained-path1", "contained1"), + new AutorouteEntry("container", "contained-path2", "contained2") + }; + + entries.AddEntries(initialEntries); + + // Act + var updatedEntries = new List() + { + new AutorouteEntry("container", "container-path"), + new AutorouteEntry("container", "contained-path1", "contained1") + }; + + entries.AddEntries(updatedEntries); + var result = entries.TryGetEntryByPath("/contained-path2", out var entry); + + // Test + Assert.False(result); + } + + [Fact] + public void RemovesOldContainedPaths() + { + // Setup + var entries = new AutorouteEntries(); + + var initialEntries = new List() + { + new AutorouteEntry("container", "container-path"), + new AutorouteEntry("container", "contained-path-old", "contained") + }; + + entries.AddEntries(initialEntries); + + // Act + var updatedEntries = new List() + { + new AutorouteEntry("container", "container-path"), + new AutorouteEntry("container", "contained-path-new", "contained") + }; + + entries.AddEntries(updatedEntries); + var result = entries.TryGetEntryByPath("/contained-path-old", out var entry); + + // Test + Assert.False(result); + } + + [Fact] + public void RemovesOldPaths() + { + // Setup + var entries = new AutorouteEntries(); + + entries.AddEntry("container", "container-path"); + + // Act + entries.RemoveEntry("container", "container-path"); + var result = entries.TryGetEntryByPath("/container-path", out var entry); + + // Test + Assert.False(result); + } + } +}