-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
SPAL-17: Content Set feature
- Loading branch information
Showing
16 changed files
with
574 additions
and
0 deletions.
There are no files selected for viewing
25 changes: 25 additions & 0 deletions
25
Lombiq.HelpfulExtensions/Controllers/ContentSetController.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Services; | ||
using Microsoft.AspNetCore.Mvc; | ||
using OrchardCore; | ||
using OrchardCore.Modules; | ||
using System.Threading.Tasks; | ||
|
||
namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Controllers; | ||
|
||
[Feature(FeatureIds.ContentSets)] | ||
public class ContentSetController : Controller | ||
{ | ||
private readonly IContentSetManager _contentSetManager; | ||
private readonly IOrchardHelper _orchardHelper; | ||
|
||
public ContentSetController(IContentSetManager contentSetManager, IOrchardHelper orchardHelper) | ||
{ | ||
_contentSetManager = contentSetManager; | ||
_orchardHelper = orchardHelper; | ||
} | ||
|
||
public async Task<IActionResult> Create(string fromContentItemId, string fromPartName, string newKey) => | ||
await _contentSetManager.CloneContentItemAsync(fromContentItemId, fromPartName, newKey) is { } content | ||
? Redirect(_orchardHelper.GetItemEditUrl(content)) | ||
: NotFound(); | ||
} |
132 changes: 132 additions & 0 deletions
132
Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetPartDisplayDriver.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Events; | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Services; | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels; | ||
using Lombiq.HelpfulLibraries.OrchardCore.Contents; | ||
using Microsoft.Extensions.Localization; | ||
using OrchardCore.ContentManagement; | ||
using OrchardCore.ContentManagement.Display.ContentDisplay; | ||
using OrchardCore.ContentManagement.Display.Models; | ||
using OrchardCore.ContentManagement.Metadata.Models; | ||
using OrchardCore.DisplayManagement.ModelBinding; | ||
using OrchardCore.DisplayManagement.Views; | ||
using OrchardCore.Entities; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using System.Threading.Tasks; | ||
|
||
namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Drivers; | ||
|
||
public class ContentSetPartDisplayDriver : ContentPartDisplayDriver<ContentSetPart> | ||
{ | ||
private const string ShapeType = $"{nameof(ContentSetPart)}_{CommonContentDisplayTypes.SummaryAdmin}"; | ||
|
||
private readonly IContentSetManager _contentSetManager; | ||
private readonly IIdGenerator _idGenerator; | ||
private readonly IEnumerable<IContentSetEventHandler> _contentSetEventHandlers; | ||
private readonly IStringLocalizer<ContentSetPartDisplayDriver> T; | ||
|
||
public ContentSetPartDisplayDriver( | ||
IContentSetManager contentSetManager, | ||
IIdGenerator idGenerator, | ||
IEnumerable<IContentSetEventHandler> contentSetEventHandlers, | ||
IStringLocalizer<ContentSetPartDisplayDriver> stringLocalizer) | ||
{ | ||
_contentSetManager = contentSetManager; | ||
_idGenerator = idGenerator; | ||
_contentSetEventHandlers = contentSetEventHandlers; | ||
T = stringLocalizer; | ||
} | ||
|
||
public override IDisplayResult Display(ContentSetPart part, BuildPartDisplayContext context) => | ||
Combine( | ||
Initialize<ContentSetPartViewModel>( | ||
$"{ShapeType}_Tags", | ||
model => BuildViewModelAsync(model, part, context.TypePartDefinition, isNew: false)) | ||
.Location(CommonContentDisplayTypes.SummaryAdmin, "Tags:11"), | ||
Initialize<ContentSetPartViewModel>( | ||
$"{ShapeType}_Links", | ||
model => BuildViewModelAsync(model, part, context.TypePartDefinition, isNew: false)) | ||
.Location(CommonContentDisplayTypes.SummaryAdmin, "Actions:5") | ||
); | ||
|
||
public override IDisplayResult Edit(ContentSetPart part, BuildPartEditorContext context) => | ||
Initialize<ContentSetPartViewModel>( | ||
$"{nameof(ContentSetPart)}_Edit", | ||
model => BuildViewModelAsync(model, part, context.TypePartDefinition, context.IsNew)) | ||
.Location($"Parts:0%{context.TypePartDefinition.Name};0"); | ||
|
||
public override async Task<IDisplayResult> UpdateAsync( | ||
ContentSetPart part, | ||
IUpdateModel updater, | ||
UpdatePartEditorContext context) | ||
{ | ||
var viewModel = new ContentSetPartViewModel(); | ||
|
||
if (await updater.TryUpdateModelAsync(viewModel, Prefix)) | ||
{ | ||
part.Key = viewModel.Key; | ||
|
||
// Need to do this here to support displaying the message to save before adding when the | ||
// item has not been saved yet. | ||
if (string.IsNullOrEmpty(part.ContentSet)) | ||
{ | ||
part.ContentSet = _idGenerator.GenerateUniqueId(); | ||
} | ||
} | ||
|
||
return await EditAsync(part, context); | ||
} | ||
|
||
public async ValueTask BuildViewModelAsync( | ||
ContentSetPartViewModel model, | ||
ContentSetPart part, | ||
ContentTypePartDefinition definition, | ||
bool isNew) | ||
{ | ||
model.Key = part.Key; | ||
model.ContentSet = part.ContentSet; | ||
model.ContentSetPart = part; | ||
model.Definition = definition; | ||
model.IsNew = isNew; | ||
|
||
var existingContentItems = (await _contentSetManager.GetContentItemsAsync(part.ContentSet)) | ||
.ToDictionary(item => item.Get<ContentSetPart>(definition.Name)?.Key); | ||
|
||
var options = new Dictionary<string, ContentSetLinkViewModel> | ||
{ | ||
[ContentSetPart.Default] = new( | ||
IsDeleted: false, | ||
T["Default content item"], | ||
existingContentItems.GetMaybe(ContentSetPart.Default)?.ContentItemId, | ||
ContentSetPart.Default), | ||
}; | ||
|
||
var supportedOptions = (await _contentSetEventHandlers.AwaitEachAsync(item => item.GetSupportedOptionsAsync(part, definition))) | ||
.SelectMany(links => links ?? Enumerable.Empty<ContentSetLinkViewModel>()); | ||
options.AddRange(supportedOptions, link => link.Key); | ||
|
||
// Ensure the existing content item IDs are applied to the supported option links. | ||
existingContentItems | ||
.Where(pair => options.GetMaybe(pair.Key)?.ContentItemId != pair.Value.ContentItemId) | ||
.ForEach(pair => options[pair.Key] = options[pair.Key] with { ContentItemId = pair.Value.ContentItemId }); | ||
|
||
// Content items that have been added to the set but no longer generate a valid option matching their key. | ||
var inapplicableSetMembers = existingContentItems | ||
.Where(pair => !options.ContainsKey(pair.Key)) | ||
.Select(pair => new ContentSetLinkViewModel( | ||
IsDeleted: true, | ||
T["{0} (No longer applicable)", pair.Value.DisplayText].Value, | ||
pair.Value.ContentItemId, | ||
pair.Key)); | ||
options.AddRange(inapplicableSetMembers, link => link.Key); | ||
|
||
model.MemberLinks = options | ||
.Values | ||
.Where(link => link.Key != model.Key && link.ContentItemId != part.ContentItem.ContentItemId) | ||
.OrderBy(link => string.IsNullOrEmpty(link.ContentItemId) ? 1 : 0) | ||
.ThenBy(link => link.IsDeleted ? 1 : 0) | ||
.ThenBy(link => link.DisplayText) | ||
.ToList(); | ||
} | ||
} |
43 changes: 43 additions & 0 deletions
43
Lombiq.HelpfulExtensions/Extensions/ContentSets/Events/IContentSetEventHandler.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels; | ||
using OrchardCore.ContentManagement; | ||
using OrchardCore.ContentManagement.Metadata.Models; | ||
using System.Collections.Generic; | ||
using System.Threading.Tasks; | ||
|
||
namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Events; | ||
|
||
/// <summary> | ||
/// Events relating to <see cref="ContentSetPart"/> containing content items. | ||
/// </summary> | ||
public interface IContentSetEventHandler | ||
{ | ||
/// <summary> | ||
/// Returns the available items relating to the content item that contains the <paramref name="part"/>. This can be | ||
/// used for a dropdown to access the other contents in the set. | ||
/// </summary> | ||
/// <returns> | ||
/// A collection of option links, or <see langword="null"/> if this even handler is not applicable for the <paramref | ||
/// name="part"/>. | ||
/// </returns> | ||
Task<IEnumerable<ContentSetLinkViewModel>> GetSupportedOptionsAsync( | ||
ContentSetPart part, | ||
ContentTypePartDefinition definition) => | ||
Task.FromResult<IEnumerable<ContentSetLinkViewModel>>(null); | ||
|
||
/// <summary> | ||
/// The event triggered when a donor content item is cloned but before it's published. | ||
/// </summary> | ||
/// <param name="content">The new content item.</param> | ||
/// <param name="definition"> | ||
/// The part definition indicating which <see cref="ContentSetPart"/> is responsible for this event. | ||
/// </param> | ||
/// <param name="contentSet">The unique ID of the content set.</param> | ||
/// <param name="newKey">The new item's key, which is unique within the content set.</param> | ||
Task CreatingAsync( | ||
ContentItem content, | ||
ContentTypePartDefinition definition, | ||
string contentSet, | ||
string newKey) => | ||
Task.CompletedTask; | ||
} |
56 changes: 56 additions & 0 deletions
56
Lombiq.HelpfulExtensions/Extensions/ContentSets/Indexes/ContentSetIndex.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; | ||
using Microsoft.Extensions.DependencyInjection; | ||
using OrchardCore.ContentManagement; | ||
using OrchardCore.ContentManagement.Metadata; | ||
using System; | ||
using System.Linq; | ||
using YesSql.Indexes; | ||
|
||
namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Indexes; | ||
|
||
public class ContentSetIndex : MapIndex | ||
{ | ||
public string ContentItemId { get; set; } | ||
public string PartName { get; set; } | ||
public bool IsPublished { get; set; } | ||
public string ContentSet { get; set; } | ||
public string Key { get; set; } | ||
|
||
public static ContentSetIndex FromPart(ContentSetPart part, string partName) => | ||
new() | ||
{ | ||
ContentItemId = part.ContentItem.ContentItemId, | ||
PartName = partName, | ||
IsPublished = part.ContentItem.Published, | ||
ContentSet = part.ContentSet, | ||
Key = part.Key, | ||
}; | ||
} | ||
|
||
public class ContentSetIndexProvider : IndexProvider<ContentItem> | ||
{ | ||
private readonly IServiceProvider _provider; | ||
|
||
// We can't inject Lazy<IContentDefinitionManager> because it will throw a "Cannot resolve scoped service | ||
// 'OrchardCore.ContentManagement.Metadata.IContentDefinitionManager' from root provider." exception. | ||
public ContentSetIndexProvider(IServiceProvider provider) => | ||
_provider = provider; | ||
|
||
public override void Describe(DescribeContext<ContentItem> context) => | ||
context.For<ContentSetIndex>().Map(contentItem => | ||
{ | ||
if (!contentItem.Latest) return Enumerable.Empty<ContentSetIndex>(); | ||
|
||
using var scope = _provider.CreateScope(); | ||
var contentDefinitionManager = scope.ServiceProvider.GetRequiredService<IContentDefinitionManager>(); | ||
|
||
return contentDefinitionManager | ||
.GetTypeDefinition(contentItem.ContentType) | ||
.Parts | ||
.Where(part => part.PartDefinition.Name == nameof(ContentSetPart)) | ||
.Select(part => new { Part = contentItem.Get<ContentSetPart>(part.Name), part.Name }) | ||
.Where(info => info.Part != null) | ||
.Select(info => ContentSetIndex.FromPart(info.Part, info.Name)) | ||
.Where(index => index.ContentSet != null); | ||
}); | ||
} |
34 changes: 34 additions & 0 deletions
34
Lombiq.HelpfulExtensions/Extensions/ContentSets/Migrations.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Indexes; | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; | ||
using Lombiq.HelpfulLibraries.OrchardCore.Data; | ||
using OrchardCore.ContentManagement.Metadata; | ||
using OrchardCore.ContentManagement.Metadata.Settings; | ||
using OrchardCore.Data.Migration; | ||
using YesSql.Sql; | ||
|
||
namespace Lombiq.HelpfulExtensions.Extensions.ContentSets; | ||
|
||
public class Migrations : DataMigration | ||
{ | ||
private readonly IContentDefinitionManager _contentDefinitionManager; | ||
|
||
public Migrations(IContentDefinitionManager contentDefinitionManager) => | ||
_contentDefinitionManager = contentDefinitionManager; | ||
|
||
public int Create() | ||
{ | ||
_contentDefinitionManager.AlterPartDefinition(nameof(ContentSetPart), builder => builder | ||
.Attachable() | ||
.Reusable() | ||
.WithDisplayName("Content Set")); | ||
|
||
SchemaBuilder.CreateMapIndexTable<ContentSetIndex>(table => table | ||
.Column<string>(nameof(ContentSetIndex.ContentItemId), column => column.WithCommonUniqueIdLength()) | ||
.Column<string>(nameof(ContentSetIndex.PartName)) | ||
.Column<bool>(nameof(ContentSetIndex.IsPublished)) | ||
.Column<string>(nameof(ContentSetIndex.ContentSet)) | ||
.Column<string>(nameof(ContentSetIndex.Key))); | ||
|
||
return 1; | ||
} | ||
} |
15 changes: 15 additions & 0 deletions
15
Lombiq.HelpfulExtensions/Extensions/ContentSets/Models/ContentSetPart.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
using OrchardCore.ContentManagement; | ||
using System.Text.Json.Serialization; | ||
|
||
namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; | ||
|
||
public class ContentSetPart : ContentPart | ||
{ | ||
public const string Default = nameof(Default); | ||
|
||
public string ContentSet { get; set; } | ||
public string Key { get; set; } = Default; | ||
|
||
[JsonIgnore] | ||
public bool IsDefault => Key == Default; | ||
} |
74 changes: 74 additions & 0 deletions
74
Lombiq.HelpfulExtensions/Extensions/ContentSets/Services/ContentSetManager.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Events; | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Indexes; | ||
using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; | ||
using OrchardCore.ContentManagement; | ||
using OrchardCore.ContentManagement.Metadata; | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using System.Threading.Tasks; | ||
using YesSql; | ||
|
||
namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Services; | ||
|
||
public class ContentSetManager : IContentSetManager | ||
{ | ||
private readonly IContentDefinitionManager _contentDefinitionManager; | ||
private readonly IContentManager _contentManager; | ||
private readonly IEnumerable<IContentSetEventHandler> _contentSetEventHandlers; | ||
private readonly ISession _session; | ||
|
||
public ContentSetManager( | ||
IContentDefinitionManager contentDefinitionManager, | ||
IContentManager contentManager, | ||
IEnumerable<IContentSetEventHandler> contentSetEventHandlers, | ||
ISession session) | ||
{ | ||
_contentDefinitionManager = contentDefinitionManager; | ||
_contentManager = contentManager; | ||
_contentSetEventHandlers = contentSetEventHandlers; | ||
_session = session; | ||
} | ||
|
||
public Task<IEnumerable<ContentSetIndex>> GetIndexAsync(string setId) => | ||
_session.QueryIndex<ContentSetIndex>(index => index.ContentSet == setId).ListAsync(); | ||
|
||
public async Task<IEnumerable<ContentItem>> GetContentItemsAsync(string setId) => | ||
await _contentManager.GetAsync((await GetIndexAsync(setId)).Select(index => index.ContentItemId)); | ||
|
||
public async Task<ContentItem> CloneContentItemAsync(string fromContentItemId, string fromPartName, string newKey) | ||
{ | ||
if (string.IsNullOrEmpty(fromPartName)) fromPartName = nameof(ContentSetPart); | ||
|
||
if (await _contentManager.GetAsync(fromContentItemId) is not { } original || | ||
original.Get<ContentSetPart>(fromPartName)?.ContentSet is not { } contentSet || | ||
await _contentManager.CloneAsync(original) is not { } content) | ||
{ | ||
return null; | ||
} | ||
|
||
var exists = await _session | ||
.QueryIndex<ContentSetIndex>(index => index.ContentSet == contentSet && index.Key == newKey) | ||
.FirstOrDefaultAsync() is not null; | ||
if (exists) throw new InvalidOperationException($"The key \"{newKey}\" already exists for the content set \"{contentSet}\"."); | ||
|
||
content.Alter<ContentSetPart>(fromPartName, part => | ||
{ | ||
part.ContentSet = contentSet; | ||
part.Key = newKey; | ||
}); | ||
|
||
var contentTypePartDefinition = _contentDefinitionManager | ||
.GetTypeDefinition(content.ContentType) | ||
.Parts | ||
.Single(definition => definition.Name == fromPartName); | ||
|
||
foreach (var handler in _contentSetEventHandlers) | ||
{ | ||
await handler.CreatingAsync(content, contentTypePartDefinition, contentSet, newKey); | ||
} | ||
|
||
await _contentManager.PublishAsync(content); | ||
return content; | ||
} | ||
} |
Oops, something went wrong.