Skip to content

Commit

Permalink
Merge pull request #135 from Lombiq/issue/SPAL-17
Browse files Browse the repository at this point in the history
SPAL-17: Content Set feature
  • Loading branch information
Psichorex authored Jul 11, 2023
2 parents b23f37f + efae169 commit ae129e5
Show file tree
Hide file tree
Showing 16 changed files with 574 additions and 0 deletions.
25 changes: 25 additions & 0 deletions Lombiq.HelpfulExtensions/Controllers/ContentSetController.cs
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();
}
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();
}
}
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;
}
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 Lombiq.HelpfulExtensions/Extensions/ContentSets/Migrations.cs
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;
}
}
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;
}
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;
}
}
Loading

0 comments on commit ae129e5

Please sign in to comment.