Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SPAL-17: Content Set feature #135

Merged
merged 31 commits into from
Jul 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
28c8a77
Add new feature.
sarahelsaig Jun 27, 2023
6c6f447
Add stub service and event.
sarahelsaig Jun 27, 2023
3dbf37b
Add tag views, controller, and driver.
sarahelsaig Jun 27, 2023
4031d58
Startup
sarahelsaig Jun 27, 2023
3df3214
Add migration and index.
sarahelsaig Jun 27, 2023
cc0b78d
manager method implementation.
sarahelsaig Jun 27, 2023
0607169
Merge remote-tracking branch 'origin/dev' into issue/SPAL-17
sarahelsaig Jun 27, 2023
52cd910
Move logic into service, add documentation.
sarahelsaig Jun 27, 2023
55f0887
No duplicate keys.
sarahelsaig Jun 27, 2023
14e9da0
Event handler documentation.
sarahelsaig Jun 27, 2023
9c5329a
Code styling.
sarahelsaig Jun 27, 2023
f6f8b38
Fix namespace.
sarahelsaig Jun 28, 2023
48aaf83
Fix dependency.
sarahelsaig Jun 29, 2023
70d13fc
Fix part migration.
sarahelsaig Jun 29, 2023
bac75cb
fxi editor view
sarahelsaig Jun 29, 2023
befee51
Fix ContentSetPart.MemberLink shape.
sarahelsaig Jun 30, 2023
9720e5d
Merge remote-tracking branch 'origin/dev' into issue/SPAL-17
sarahelsaig Jun 30, 2023
9546a09
Merge remote-tracking branch 'origin/dev' into issue/SPAL-17
sarahelsaig Jul 4, 2023
8064f29
Fix index.
sarahelsaig Jul 4, 2023
720df21
fix links
sarahelsaig Jul 4, 2023
3a0ea8e
content set part bug fixes
sarahelsaig Jul 5, 2023
fcfe551
Clean up driver edit.
sarahelsaig Jul 5, 2023
3c8e0e1
minor bug fixes
sarahelsaig Jul 5, 2023
1c66c83
Ensure the existing content item IDs are applied to the supported opt…
sarahelsaig Jul 5, 2023
76566b1
Fix content set id retention.
sarahelsaig Jul 5, 2023
36f8592
Fix part name in links and tags.
sarahelsaig Jul 5, 2023
19cb754
Code cleanup.
sarahelsaig Jul 7, 2023
0c3b42a
Skip index for broken content set entries.
sarahelsaig Jul 7, 2023
c7b6b61
Replace GetContentItemIdsAsync with GetIndexAsync,.
sarahelsaig Jul 7, 2023
9dda1e9
Merge branch 'dev' into issue/SPAL-17
sarahelsaig Jul 9, 2023
efae169
Typo
sarahelsaig Jul 9, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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