diff --git a/Lombiq.HelpfulExtensions.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs b/Lombiq.HelpfulExtensions.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs index 8fd6ba22..3765cc78 100644 --- a/Lombiq.HelpfulExtensions.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs +++ b/Lombiq.HelpfulExtensions.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs @@ -5,6 +5,7 @@ using OpenQA.Selenium; using OpenQA.Selenium.Interactions; using Shouldly; +using System.Linq; using System.Threading.Tasks; using static Lombiq.HelpfulExtensions.Tests.UI.Constants.TextInputValues; using static Lombiq.HelpfulExtensions.Tests.UI.Constants.XPathSelectors; @@ -135,6 +136,57 @@ await context.RetryIfStaleOrFailAsync(async () => }); } + /// + /// Tests the Lombiq Helpful Extensions - Content Sets feature. + /// + public static async Task TestFeatureContentSetsAsync(this UITestContext context) + { + const string contentId0 = "contentsetexample000000000"; + const string contentId2 = "contentsetexample000000002"; + + var byLink = By.CssSelector(".field-name-content-set-example-content-set-type .value a"); + + void VerifyDisplay(string title, string body, params string[] linkTexts) + { + var contentItem = context.Get(By.ClassName("content-set-example")); + + contentItem.Get(By.TagName("h1")).Text.Trim().ShouldBe(title); + contentItem.Get(By.ClassName("content-set-example-body")).Text.Trim().ShouldBe(body); + + contentItem + .GetAll(byLink) + .Select(link => link.Text.Trim()) + .ToArray() + .ShouldBe(linkTexts); + } + + // Verify the default item. + await context.SignInDirectlyAsync(); + await context.GoToRelativeUrlAsync($"/Contents/ContentItems/{contentId0}"); + VerifyDisplay("Default Content Set Example", "Some generic text.", "Other Example", "Some Example"); + + // Verify the first item both by content set content picker link and direct access. + await context.ClickReliablyOnAsync(byLink); + VerifyDisplay("Second Content Set Variant", "Some generic text v2.", "Default content item", "Some Example"); + await context.GoToRelativeUrlAsync($"/Contents/ContentItems/{contentId2}"); + VerifyDisplay("Second Content Set Variant", "Some generic text v2.", "Default content item", "Some Example"); + + // Create the final variant. + await context.GoToContentItemListAsync("ContentSetExample"); + await context.SelectFromBootstrapDropdownReliablyAsync( + context.Get(By.XPath("//li[contains(@class, 'list-group-item')][3]//div[@title='Content Set Type']//button")), + By.XPath("//a[@title='Create Final Example']")); + await context.ClickAndFillInWithRetriesAsync(By.Id("TitlePart_Title"), "Test Title"); + await context.ClickPublishAsync(); + context.ShouldBeSuccess(); + + // Verify changes. + await context.GoToRelativeUrlAsync($"/Contents/ContentItems/{contentId0}"); + VerifyDisplay("Default Content Set Example", "Some generic text.", "Final Example", "Other Example", "Some Example"); + await context.ClickReliablyOnAsync(byLink); + VerifyDisplay("Test Title", "Some generic text v1.", "Default content item", "Other Example", "Some Example"); + } + private static async Task TestWidgetAsync(this UITestContext context, string widget) { await context.GoToCreatePageAsync(); diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetContentPickerFieldDisplayDriver.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetContentPickerFieldDisplayDriver.cs new file mode 100644 index 00000000..644cfa03 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetContentPickerFieldDisplayDriver.cs @@ -0,0 +1,61 @@ +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 Newtonsoft.Json.Linq; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Display.ContentDisplay; +using OrchardCore.ContentManagement.Display.Models; +using OrchardCore.ContentManagement.Metadata; +using OrchardCore.ContentManagement.Metadata.Models; +using OrchardCore.DisplayManagement.Views; +using System.Collections.Generic; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Drivers; + +public class ContentSetContentPickerFieldDisplayDriver : ContentFieldDisplayDriver +{ + private readonly IContentDefinitionManager _contentDefinitionManager; + private readonly IContentSetManager _contentSetManager; + private readonly IEnumerable _contentSetEventHandlers; + private readonly IStringLocalizer T; + + public ContentSetContentPickerFieldDisplayDriver( + IContentDefinitionManager contentDefinitionManager, + IContentSetManager contentSetManager, + IEnumerable contentSetEventHandlers, + IStringLocalizer stringLocalizer) + { + _contentDefinitionManager = contentDefinitionManager; + _contentSetManager = contentSetManager; + _contentSetEventHandlers = contentSetEventHandlers; + T = stringLocalizer; + } + + public override IDisplayResult Display( + ContentSetContentPickerField field, + BuildFieldDisplayContext fieldDisplayContext) + { + var name = fieldDisplayContext.PartFieldDefinition.Name; + if (field.ContentItem.Get(name) is not { } part) return null; + + return Initialize(GetDisplayShapeType(fieldDisplayContext), model => + { + model.PartFieldDefinition = fieldDisplayContext.PartFieldDefinition; + return model.InitializeAsync( + _contentSetManager, + _contentSetEventHandlers, + T, + part, + new ContentTypePartDefinition( + name, + _contentDefinitionManager.GetPartDefinition(nameof(ContentSetPart)), + new JObject()), + isNew: false); + }) + .Location(CommonContentDisplayTypes.Detail, CommonLocationNames.Content) + .Location(CommonContentDisplayTypes.Summary, CommonLocationNames.Content); + } +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetPartDisplayDriver.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetPartDisplayDriver.cs index 90f80478..9b7f7cf1 100644 --- a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetPartDisplayDriver.cs +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetPartDisplayDriver.cs @@ -4,15 +4,12 @@ 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; @@ -24,13 +21,13 @@ public class ContentSetPartDisplayDriver : ContentPartDisplayDriver _contentSetEventHandlers; - private readonly IStringLocalizer T; + private readonly IStringLocalizer T; public ContentSetPartDisplayDriver( IContentSetManager contentSetManager, IIdGenerator idGenerator, IEnumerable contentSetEventHandlers, - IStringLocalizer stringLocalizer) + IStringLocalizer stringLocalizer) { _contentSetManager = contentSetManager; _idGenerator = idGenerator; @@ -38,22 +35,33 @@ public ContentSetPartDisplayDriver( T = stringLocalizer; } - public override IDisplayResult Display(ContentSetPart part, BuildPartDisplayContext context) => - Combine( - Initialize( - $"{ShapeType}_Tags", - model => BuildViewModelAsync(model, part, context.TypePartDefinition, isNew: false)) + public override IDisplayResult Display(ContentSetPart part, BuildPartDisplayContext context) + { + ValueTask InitializeAsync(ContentSetPartViewModel model) => + model.InitializeAsync( + _contentSetManager, + _contentSetEventHandlers, + T, + part, + context.TypePartDefinition, + isNew: false); + + return Combine( + Initialize($"{ShapeType}_Tags", InitializeAsync) .Location(CommonContentDisplayTypes.SummaryAdmin, "Tags:11"), - Initialize( - $"{ShapeType}_Links", - model => BuildViewModelAsync(model, part, context.TypePartDefinition, isNew: false)) + Initialize($"{ShapeType}_Links", InitializeAsync) .Location(CommonContentDisplayTypes.SummaryAdmin, "Actions:5") ); + } public override IDisplayResult Edit(ContentSetPart part, BuildPartEditorContext context) => - Initialize( - $"{nameof(ContentSetPart)}_Edit", - model => BuildViewModelAsync(model, part, context.TypePartDefinition, context.IsNew)) + Initialize($"{nameof(ContentSetPart)}_Edit", model => model.InitializeAsync( + _contentSetManager, + _contentSetEventHandlers, + T, + part, + context.TypePartDefinition, + context.IsNew)) .Location($"Parts:0%{context.TypePartDefinition.Name};0"); public override async Task UpdateAsync( @@ -77,56 +85,4 @@ public override async Task UpdateAsync( 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(definition.Name)?.Key); - - var options = new Dictionary - { - [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()); - 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(); - } } diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Models/ContentSetContentPickerField.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Models/ContentSetContentPickerField.cs new file mode 100644 index 00000000..cc50fbeb --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Models/ContentSetContentPickerField.cs @@ -0,0 +1,12 @@ +using OrchardCore.ContentManagement; +using System.Diagnostics.CodeAnalysis; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; + +[SuppressMessage( + "Minor Code Smell", + "S2094:Classes should not be empty", + Justification = "Only data we need is the field name.")] +public class ContentSetContentPickerField : ContentField +{ +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Startup.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Startup.cs index 4a5f3d97..ecb4456f 100644 --- a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Startup.cs +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Startup.cs @@ -24,6 +24,9 @@ public override void ConfigureServices(IServiceCollection services) .WithMigration(); services.AddScoped(); + + services.AddContentField() + .UseDisplayDriver(); } public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/ViewModels/ContentSetContentPickerFieldViewModel.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/ViewModels/ContentSetContentPickerFieldViewModel.cs new file mode 100644 index 00000000..d0799f27 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/ViewModels/ContentSetContentPickerFieldViewModel.cs @@ -0,0 +1,8 @@ +using OrchardCore.ContentManagement.Metadata.Models; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels; + +public class ContentSetContentPickerFieldViewModel : ContentSetPartViewModel +{ + public ContentPartFieldDefinition PartFieldDefinition { get; set; } +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/ViewModels/ContentSetPartViewModel.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/ViewModels/ContentSetPartViewModel.cs index f97b3cbb..641bc031 100644 --- a/Lombiq.HelpfulExtensions/Extensions/ContentSets/ViewModels/ContentSetPartViewModel.cs +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/ViewModels/ContentSetPartViewModel.cs @@ -1,8 +1,14 @@ -using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Events; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Services; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Localization; +using OrchardCore.ContentManagement; using OrchardCore.ContentManagement.Metadata.Models; +using OrchardCore.ContentManagement.Metadata.Settings; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels; @@ -22,6 +28,70 @@ public class ContentSetPartViewModel [BindNever] public bool IsNew { get; set; } + + [BindNever] + public string DisplayName => + Definition? + .Settings? + .Property(nameof(ContentTypePartSettings))? + .Value + .ToObject()? + .DisplayName ?? Definition?.Name; + + public async ValueTask InitializeAsync( + IContentSetManager contentSetManager, + IEnumerable contentSetEventHandlers, + IStringLocalizer stringLocalizer, + ContentSetPart part, + ContentTypePartDefinition definition, + bool isNew) + { + Key = part.Key; + ContentSet = part.ContentSet; + ContentSetPart = part; + Definition = definition; + IsNew = isNew; + + var existingContentItems = (await contentSetManager.GetContentItemsAsync(part.ContentSet)) + .ToDictionary(item => item.Get(definition.Name)?.Key); + + var options = new Dictionary + { + [ContentSetPart.Default] = new( + IsDeleted: false, + stringLocalizer["Default content item"], + existingContentItems.GetMaybe(ContentSetPart.Default)?.ContentItemId, + ContentSetPart.Default), + }; + + var supportedOptions = (await contentSetEventHandlers.AwaitEachAsync(item => item.GetSupportedOptionsAsync(part, definition))) + .Where(links => links != null) + .SelectMany(links => links); + 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, + stringLocalizer["{0} (No longer applicable)", pair.Value.DisplayText].Value, + pair.Value.ContentItemId, + pair.Key)); + options.AddRange(inapplicableSetMembers, link => link.Key); + + MemberLinks = options + .Values + .Where(link => link.Key != 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(); + } } public record ContentSetLinkViewModel(bool IsDeleted, string DisplayText, string ContentItemId, string Key); diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Activities/ContentSetCreatingEvent.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Activities/ContentSetCreatingEvent.cs new file mode 100644 index 00000000..c7462aab --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Activities/ContentSetCreatingEvent.cs @@ -0,0 +1,15 @@ +using Lombiq.HelpfulLibraries.OrchardCore.Workflow; +using Microsoft.Extensions.Localization; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Activities; + +public class ContentSetCreatingEvent : SimpleEventActivityBase +{ + public override LocalizedString DisplayText => T["Creating Content Set"]; + public override LocalizedString Category => T["Content Sets"]; + + public ContentSetCreatingEvent(IStringLocalizer stringLocalizer) + : base(stringLocalizer) + { + } +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Activities/ContentSetGetSupportedOptionsEvent.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Activities/ContentSetGetSupportedOptionsEvent.cs new file mode 100644 index 00000000..239f4030 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Activities/ContentSetGetSupportedOptionsEvent.cs @@ -0,0 +1,17 @@ +using Lombiq.HelpfulLibraries.OrchardCore.Workflow; +using Microsoft.Extensions.Localization; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Activities; + +public class ContentSetGetSupportedOptionsEvent : SimpleEventActivityBase +{ + public const string OutputName = "MemberLinks"; + + public override LocalizedString DisplayText => T["Get Supported Content Set Options"]; + public override LocalizedString Category => T["Content Sets"]; + + public ContentSetGetSupportedOptionsEvent(IStringLocalizer stringLocalizer) + : base(stringLocalizer) + { + } +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Drivers/ContentSetCreatingEventDisplayDriver.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Drivers/ContentSetCreatingEventDisplayDriver.cs new file mode 100644 index 00000000..e452782e --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Drivers/ContentSetCreatingEventDisplayDriver.cs @@ -0,0 +1,33 @@ +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Activities; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Models; +using Lombiq.HelpfulLibraries.OrchardCore.Workflow; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.Extensions.Localization; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Metadata.Models; +using OrchardCore.DisplayManagement.Notify; +using System.Collections.Generic; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Drivers; + +public class ContentSetCreatingEventDisplayDriver : DocumentedEventActivityDisplayDriverBase +{ + private readonly IHtmlLocalizer H; + + public override string IconClass => "fa-circle-half-stroke"; + public override LocalizedHtmlString Description => H["Executes when a new content item is created in the content set."]; + public override IDictionary AvailableInputs { get; } = new Dictionary + { + [nameof(CreatingContext.ContentItem)] = nameof(ContentItem), + [nameof(CreatingContext.Definition)] = nameof(ContentTypePartDefinition), + [nameof(CreatingContext.ContentSet)] = "string", + [nameof(CreatingContext.NewKey)] = "string", + }; + + public ContentSetCreatingEventDisplayDriver( + INotifier notifier, + IStringLocalizer baseLocalizer, + IHtmlLocalizer htmlLocalizer) + : base(notifier, baseLocalizer) => + H = htmlLocalizer; +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Drivers/ContentSetGetSupportedOptionsEventDisplayDriver.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Drivers/ContentSetGetSupportedOptionsEventDisplayDriver.cs new file mode 100644 index 00000000..b1bcd1df --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Drivers/ContentSetGetSupportedOptionsEventDisplayDriver.cs @@ -0,0 +1,44 @@ +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Activities; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Models; +using Lombiq.HelpfulLibraries.OrchardCore.Workflow; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.Extensions.Localization; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Metadata.Models; +using OrchardCore.DisplayManagement.Notify; +using System.Collections.Generic; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Drivers; + +public class ContentSetGetSupportedOptionsEventDisplayDriver : + DocumentedEventActivityDisplayDriverBase +{ + private readonly IHtmlLocalizer H; + + public override string IconClass => "fa-circle-half-stroke"; + + public override LocalizedHtmlString Description => + H["Tries to get a list of links representing the supported options for this content set."]; + + public override IDictionary AvailableInputs { get; } = new Dictionary + { + [nameof(GetSupportedOptionsContext.Definition)] = nameof(ContentTypePartDefinition), + [nameof(GetSupportedOptionsContext.ContentSetPart)] = nameof(ContentSetPart), + [nameof(GetSupportedOptionsContext.ContentItem)] = nameof(ContentItem), + }; + + public override IDictionary ExpectedOutputs { get; } = new Dictionary + { + [ContentSetGetSupportedOptionsEvent.OutputName] = + $"{{ \"{nameof(ContentSetLinkViewModel.Key)}\": string, \"{nameof(ContentSetLinkViewModel.DisplayText)}\": string }}[]", + }; + + public ContentSetGetSupportedOptionsEventDisplayDriver( + INotifier notifier, + IStringLocalizer baseLocalizer, + IHtmlLocalizer htmlLocalizer) + : base(notifier, baseLocalizer) => + H = htmlLocalizer; +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Handlers/WorkflowContentSetEventHandler.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Handlers/WorkflowContentSetEventHandler.cs new file mode 100644 index 00000000..f7e34c3b --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Handlers/WorkflowContentSetEventHandler.cs @@ -0,0 +1,82 @@ +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Events; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Activities; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Models; +using Lombiq.HelpfulLibraries.OrchardCore.Workflow; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Metadata.Models; +using OrchardCore.Workflows.Models; +using OrchardCore.Workflows.Services; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Threading.Tasks; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Handlers; + +public class WorkflowContentSetEventHandler : IContentSetEventHandler +{ + private readonly IWorkflowManager _workflowManager; + private readonly IWorkflowTypeStore _workflowTypeStore; + + public WorkflowContentSetEventHandler( + IWorkflowManager workflowManager, + IWorkflowTypeStore workflowTypeStore) + { + _workflowManager = workflowManager; + _workflowTypeStore = workflowTypeStore; + } + + public async Task> GetSupportedOptionsAsync( + ContentSetPart part, + ContentTypePartDefinition definition) + { + var links = new List(); + + var values = new GetSupportedOptionsContext(definition, part).ToDictionary(); + + var workflowContexts = await _workflowManager + .TriggerEventAndGetContextsAsync(_workflowTypeStore, values); + + foreach (var context in workflowContexts) + { + if (context.Status is WorkflowStatus.Faulted or WorkflowStatus.Halted or WorkflowStatus.Aborted) continue; + if (!context.Output.TryGetValue(nameof(ContentSetPartViewModel.MemberLinks), out var memberLinks)) continue; + + switch (memberLinks) + { + case IEnumerable viewModels: + links.AddRange(viewModels); + break; + case ContentSetLinkViewModel viewModel: + links.Add(viewModel); + break; + case IEnumerable collection when collection.CastWhere() is { } objects && objects.Any(): + links.AddRange(JToken.FromObject(objects).ToObject>()); + break; + case ExpandoObject expandoObject: + links.Add(JToken.FromObject(expandoObject).ToObject()); + break; + case string json when !string.IsNullOrWhiteSpace(json): + links.AddRange(JsonConvert.DeserializeObject>(json)); + break; + default: continue; + } + } + + return links; + } + + public Task CreatingAsync( + ContentItem content, + ContentTypePartDefinition definition, + string contentSet, + string newKey) => + _workflowManager.TriggerEventAsync( + new CreatingContext(content, definition, contentSet, newKey), + $"{nameof(WorkflowContentSetEventHandler)}.{nameof(CreatingAsync)}" + + $"({content.ContentItemId}, {definition.Name}, {contentSet}, {newKey})"); +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Models/CreatingContext.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Models/CreatingContext.cs new file mode 100644 index 00000000..32d4b2f0 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Models/CreatingContext.cs @@ -0,0 +1,10 @@ +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Metadata.Models; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Models; + +public record CreatingContext( + ContentItem ContentItem, + ContentTypePartDefinition Definition, + string ContentSet, + string NewKey); diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Models/GetSupportedOptionsContext.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Models/GetSupportedOptionsContext.cs new file mode 100644 index 00000000..f727c05d --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Models/GetSupportedOptionsContext.cs @@ -0,0 +1,35 @@ +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Metadata.Models; +using System.Collections.Generic; +using System.Linq; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Models; + +public record GetSupportedOptionsContext( + ContentTypePartDefinition Definition, + ContentSetPart ContentSetPart) +{ + public ContentItem ContentItem => ContentSetPart?.ContentItem; + + public IDictionary ToDictionary() => + new Dictionary + { + // We create a new type here to avoid circular references which break JSON serialization. + [nameof(Definition)] = Definition == null ? null : new + { + Definition.Name, + Definition.Settings, + PartDefinition = new + { + Definition.PartDefinition.Name, + Definition.PartDefinition.Settings, + Fields = Definition.PartDefinition.Fields + .Select(field => new { field.Name, field.Settings, field.FieldDefinition }) + .ToList(), + }, + }, + [nameof(ContentSetPart)] = ContentSetPart, + [nameof(ContentItem)] = ContentItem, + }; +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Startup.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Startup.cs new file mode 100644 index 00000000..1d8a7e8b --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Workflows/Startup.cs @@ -0,0 +1,21 @@ +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Events; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Activities; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Drivers; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows.Handlers; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Modules; +using OrchardCore.Workflows.Helpers; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Workflows; + +[Feature(FeatureIds.ContentSets)] +[RequireFeatures("OrchardCore.Workflows")] +public class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddActivity(); + services.AddActivity(); + services.AddScoped(); + } +} diff --git a/Lombiq.HelpfulExtensions/Recipes/Lombiq.HelpfulExtensions.ContentSets.Sample.recipe.json b/Lombiq.HelpfulExtensions/Recipes/Lombiq.HelpfulExtensions.ContentSets.Sample.recipe.json new file mode 100644 index 00000000..4e6983ef --- /dev/null +++ b/Lombiq.HelpfulExtensions/Recipes/Lombiq.HelpfulExtensions.ContentSets.Sample.recipe.json @@ -0,0 +1,292 @@ +{ + "name": "Lombiq.HelpfulExtensions.ContentSets.Samples", + "displayName": "Lombiq Helpful Extensions - Content Sets - Sample", + "description": "Sample content type, content items and workflow for the Content Sets feature.", + "author": "Lombiq Technologies", + "website": "https://github.com/Lombiq/Helpful-Extensions/", + "version": "1.0", + "issetuprecipe": false, + "categories": [ + "test" + ], + "tags": [ + "test" + ], + "steps": [ + { + "name": "feature", + "enable": [ + "Lombiq.HelpfulExtensions.ContentSets" + ] + }, + { + "name": "ContentDefinition", + "ContentTypes": [ + { + "Name": "ContentSetExample", + "DisplayName": "Content Set Example", + "Settings": { + "ContentTypeSettings": { + "Creatable": true, + "Listable": true, + "Draftable": false, + "Versionable": false, + "Securable": false + }, + "FullTextAspectSettings": {}, + "GraphQLContentTypeSettings": {} + }, + "ContentTypePartDefinitionRecords": [ + { + "PartName": "TitlePart", + "Name": "TitlePart", + "Settings": { + "ContentTypePartSettings": { + "Position": "0" + } + } + }, + { + "PartName": "ContentSetExample", + "Name": "ContentSetExample", + "Settings": { + "ContentTypePartSettings": { + "Position": "1" + } + } + }, + { + "PartName": "LiquidPart", + "Name": "LiquidPart", + "Settings": { + "ContentTypePartSettings": { + "Position": "2" + } + } + }, + { + "PartName": "ContentSetPart", + "Name": "ContentSetType", + "Settings": { + "ContentTypePartSettings": { + "DisplayName": "Content Set Type", + "Position": "3" + } + } + } + ] + } + ], + "ContentParts": [ + { + "Name": "ContentSetPart", + "Settings": { + "ContentPartSettings": { + "Attachable": true, + "Reusable": true, + "DisplayName": "Content Set" + } + }, + "ContentPartFieldDefinitionRecords": [] + }, + { + "Name": "ContentSetExample", + "Settings": {}, + "ContentPartFieldDefinitionRecords": [ + { + "FieldName": "ContentSetContentPickerField", + "Name": "ContentSetType", + "Settings": { + "ContentPartFieldSettings": { + "DisplayName": "Content Set Type", + "Position": "0" + } + } + } + ] + } + ] + }, + { + "name": "WorkflowType", + "data": [ + { + "WorkflowTypeId": "[js:uuid()]", + "Name": "Content Set Example Workflow", + "IsEnabled": true, + "IsSingleton": false, + "LockTimeout": 0, + "LockExpiration": 0, + "DeleteFinishedWorkflows": false, + "Activities": [ + { + "ActivityId": "4sbqa4xvasnrazvks7bnmkz04s", + "Name": "ContentSetGetSupportedOptionsEvent", + "X": 40, + "Y": 160, + "IsStart": true, + "Properties": { + "ActivityMetadata": { + "Title": null + } + } + }, + { + "ActivityId": "4az0g1kk6eh2g135cr45zkdpw8", + "Name": "IfElseTask", + "X": 420, + "Y": 160, + "IsStart": false, + "Properties": { + "ActivityMetadata": { + "Title": null + }, + "Condition": { + "Expression": "input('Definition').Name == 'ContentSetType'" + } + } + }, + { + "ActivityId": "47wbveez2chjdsygwdgqezwcdq", + "Name": "LogTask", + "X": 830, + "Y": 40, + "IsStart": false, + "Properties": { + "ActivityMetadata": { + "Title": null + }, + "LogLevel": 2, + "Text": { + "Expression": "Definition: \"{{ Workflow.Input.Definition | json }}\"." + } + } + }, + { + "ActivityId": "4pg64655zjgqgzk5s1ysj2p8mp", + "Name": "SetOutputTask", + "X": 830, + "Y": 280, + "IsStart": false, + "Properties": { + "ActivityMetadata": { + "Title": null + }, + "OutputName": "MemberLinks", + "Value": { + "Expression": "[ { \"Key\": \"Example1\", \"DisplayText\": \"Some Example\" }, { \"Key\": \"Example2\", \"DisplayText\": \"Other Example\" }, { \"Key\": \"Example3\", \"DisplayText\": \"Final Example\" } ]" + } + } + } + ], + "Transitions": [ + { + "Id": 0, + "SourceActivityId": "4sbqa4xvasnrazvks7bnmkz04s", + "SourceOutcomeName": "Done", + "DestinationActivityId": "4az0g1kk6eh2g135cr45zkdpw8" + }, + { + "Id": 0, + "SourceActivityId": "4az0g1kk6eh2g135cr45zkdpw8", + "SourceOutcomeName": "False", + "DestinationActivityId": "47wbveez2chjdsygwdgqezwcdq" + }, + { + "Id": 0, + "SourceActivityId": "4az0g1kk6eh2g135cr45zkdpw8", + "SourceOutcomeName": "True", + "DestinationActivityId": "4pg64655zjgqgzk5s1ysj2p8mp" + } + ] + } + ] + }, + { + "name": "content", + "data": [ + { + "ContentItemId": "contentsetexample000000000", + "ContentItemVersionId": "[js:uuid()]", + "ContentType": "ContentSetExample", + "DisplayText": "Default Content Set Example", + "Latest": true, + "Published": true, + "ModifiedUtc": "2023-09-10T05:39:27.0460202Z", + "PublishedUtc": "2023-09-10T05:39:27.0833929Z", + "CreatedUtc": "2023-09-10T05:39:27.0460202Z", + "Owner": null, + "Author": "admin", + "ContentSetExample": { + "ContentSetType": {} + }, + "TitlePart": { + "Title": "Default Content Set Example" + }, + "LiquidPart": { + "Liquid": "
Some generic text.
" + }, + "ContentSetType": { + "ContentSet": "4nbddxahchth32xeg28am0mack", + "Key": "Default", + "IsDefault": true + } + }, + { + "ContentItemId": "contentsetexample000000001", + "ContentItemVersionId": "[js:uuid()]", + "ContentType": "ContentSetExample", + "DisplayText": "First Content Set Variant", + "Latest": true, + "Published": true, + "ModifiedUtc": "2023-09-10T05:48:28.9723912Z", + "PublishedUtc": "2023-09-10T05:48:28.9799812Z", + "CreatedUtc": "2023-09-10T05:48:08.4129272Z", + "Owner": null, + "Author": "admin", + "ContentSetExample": { + "ContentSetType": {} + }, + "TitlePart": { + "Title": "First Content Set Variant" + }, + "LiquidPart": { + "Liquid": "
Some generic text v1.
" + }, + "ContentSetType": { + "ContentSet": "4nbddxahchth32xeg28am0mack", + "Key": "Example1", + "IsDefault": false + } + }, + { + "ContentItemId": "contentsetexample000000002", + "ContentItemVersionId": "[js:uuid()]", + "ContentType": "ContentSetExample", + "DisplayText": "Second Content Set Variant", + "Latest": true, + "Published": true, + "ModifiedUtc": "2023-09-10T15:15:33.2219943Z", + "PublishedUtc": "2023-09-10T15:15:33.2624978Z", + "CreatedUtc": "2023-09-10T15:15:25.0407535Z", + "Owner": null, + "Author": "admin", + "ContentSetExample": { + "ContentSetType": {} + }, + "TitlePart": { + "Title": "Second Content Set Variant" + }, + "LiquidPart": { + "Liquid": "
Some generic text v2.
" + }, + "ContentSetType": { + "ContentSet": "4nbddxahchth32xeg28am0mack", + "Key": "Example2", + "IsDefault": false + } + } + ] + } + ] +} diff --git a/Lombiq.HelpfulExtensions/Views/ContentSetContentPickerField.cshtml b/Lombiq.HelpfulExtensions/Views/ContentSetContentPickerField.cshtml new file mode 100644 index 00000000..4e5884f1 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Views/ContentSetContentPickerField.cshtml @@ -0,0 +1,31 @@ +@model Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels.ContentSetContentPickerFieldViewModel +@using OrchardCore +@using OrchardCore.ContentManagement.Metadata.Settings +@using OrchardCore.Mvc.Utilities + +@{ + var name = (Model.PartFieldDefinition.PartDefinition.Name + "-" + Model.PartFieldDefinition.Name).HtmlClassify(); + + var settings = Model.PartFieldDefinition.Settings.GetMaybe(nameof(ContentPartFieldSettings)); + var displayName = settings?.ToObject()?.DisplayName ?? Model.PartFieldDefinition.Name; + + var links = Model + .MemberLinks + .Where(link => + !link.IsDeleted && + link.ContentItemId != null && + link.ContentItemId != Model.ContentSetPart.ContentItem.ContentItemId) + .ToList(); + if (!links.Any()) { return; } +} + +
+ @displayName + @for (var i = 0; i < links.Count; i++) + { + var link = links[i]; + var url = Orchard.GetItemDisplayUrl(link.ContentItemId); + var separator = i < (links.Count - 1) ? ", " : string.Empty; + @link.DisplayText@separator + } +
diff --git a/Lombiq.HelpfulExtensions/Views/ContentSetPart.MemberLink.cshtml b/Lombiq.HelpfulExtensions/Views/ContentSetPart.MemberLink.cshtml index 154a5d59..bc6f1ec2 100644 --- a/Lombiq.HelpfulExtensions/Views/ContentSetPart.MemberLink.cshtml +++ b/Lombiq.HelpfulExtensions/Views/ContentSetPart.MemberLink.cshtml @@ -15,7 +15,7 @@ { var url = Orchard.Action(controller => controller.Edit( link.ContentItemId)); - + @link.DisplayText } @@ -25,7 +25,7 @@ contentSetPart.ContentItem.ContentItemId, definition.Name, link.Key)); - + @link.DisplayText } diff --git a/Lombiq.HelpfulExtensions/Views/ContentSetPart.SummaryAdmin.Links.cshtml b/Lombiq.HelpfulExtensions/Views/ContentSetPart.SummaryAdmin.Links.cshtml index 538a906b..e45697aa 100644 --- a/Lombiq.HelpfulExtensions/Views/ContentSetPart.SummaryAdmin.Links.cshtml +++ b/Lombiq.HelpfulExtensions/Views/ContentSetPart.SummaryAdmin.Links.cshtml @@ -3,12 +3,12 @@ @{ if (string.IsNullOrEmpty(Model.ContentSet) || !Model.MemberLinks.Any()) { return; } - var title = T[Model.Definition.Name]; + var title = T[Model.DisplayName]; }