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

OSOE-680: Add default handler, example and docs for the Content Sets feature in Lombiq.HelpfulExtensions #143

Merged
merged 32 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
bd175f7
Add workflow event handler
sarahelsaig Sep 5, 2023
7aa9f9a
Make the event handler invocation a bit more readable.
sarahelsaig Sep 5, 2023
58b7f06
Add and use ContentSetGetSupportedOptionsEvent.
sarahelsaig Sep 6, 2023
314ab64
Add and use ContentSetCreatingEvent.
sarahelsaig Sep 7, 2023
fee2d8c
Move activities to the correct directory.
sarahelsaig Sep 7, 2023
a3b5440
Move all workflow stuff into the Workflows subdirectory.
sarahelsaig Sep 7, 2023
f47bcfe
Bug fixes.
sarahelsaig Sep 7, 2023
02f0dfe
Create a new type to avoid circular references which break JSON seria…
sarahelsaig Sep 7, 2023
cbe1e02
Fix deserialization.
sarahelsaig Sep 8, 2023
2b9999f
Use DocumentedEventActivityDisplayDriverBase.
sarahelsaig Sep 8, 2023
421cb02
Move workflow models to the correct directory.
sarahelsaig Sep 8, 2023
2b2375d
Fix summary admin.
sarahelsaig Sep 8, 2023
2ec32fd
unusing
sarahelsaig Sep 8, 2023
2bcb261
Add documentation.
sarahelsaig Sep 8, 2023
fe0b9a8
Fix readme.
sarahelsaig Sep 10, 2023
270a8be
Add display action.
sarahelsaig Sep 10, 2023
225fff2
Fix property name.
sarahelsaig Sep 10, 2023
7839e07
Extract ContentSetPartViewModel initialization for DRY.
sarahelsaig Sep 10, 2023
98c6dc3
Add ContentSetContentPickerField.
sarahelsaig Sep 10, 2023
2294291
Add docs.
sarahelsaig Sep 10, 2023
6b50250
Recipe.
sarahelsaig Sep 10, 2023
209fdd3
Add test.
sarahelsaig Sep 10, 2023
8cfc4ad
bug fixes and adjustments
sarahelsaig Sep 10, 2023
5a9fef1
Fix test.
sarahelsaig Sep 10, 2023
19a3f4e
Fix MD032.
sarahelsaig Sep 10, 2023
367cda6
unusing
sarahelsaig Sep 10, 2023
ededa79
Merge remote-tracking branch 'origin/dev' into issue/OSOE-680
sarahelsaig Sep 10, 2023
9710067
Merge tag 'v7.0.0' into issue/OSOE-680
sarahelsaig Sep 11, 2023
eb84106
code styling
sarahelsaig Sep 11, 2023
fea9912
Update readme phrasing.
sarahelsaig Sep 11, 2023
593002b
post merge bug fix
sarahelsaig Sep 11, 2023
6b22993
Removing empty line.
Psichorex Sep 11, 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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -135,6 +136,57 @@ await context.RetryIfStaleOrFailAsync(async () =>
});
}

/// <summary>
/// Tests the Lombiq Helpful Extensions - Content Sets feature.
/// </summary>
public static async Task TestFeatureContentSetsAsync(this UITestContext context)
{
const string contentId0 = "contentsetexample000000000";
Psichorex marked this conversation as resolved.
Show resolved Hide resolved
const string contentId2 = "contentsetexample000000002";
Psichorex marked this conversation as resolved.
Show resolved Hide resolved

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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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<ContentSetContentPickerField>
{
private readonly IContentDefinitionManager _contentDefinitionManager;
private readonly IContentSetManager _contentSetManager;
private readonly IEnumerable<IContentSetEventHandler> _contentSetEventHandlers;
private readonly IStringLocalizer<ContentSetPart> T;

public ContentSetContentPickerFieldDisplayDriver(
IContentDefinitionManager contentDefinitionManager,
IContentSetManager contentSetManager,
IEnumerable<IContentSetEventHandler> contentSetEventHandlers,
IStringLocalizer<ContentSetPart> stringLocalizer)
{
_contentDefinitionManager = contentDefinitionManager;
_contentSetManager = contentSetManager;
_contentSetEventHandlers = contentSetEventHandlers;
T = stringLocalizer;
}

public override IDisplayResult Display(
ContentSetContentPickerField field,
BuildFieldDisplayContext fieldDisplayContext)
{
var name = fieldDisplayContext.PartFieldDefinition.Name;
return field.ContentItem.Get<ContentSetPart>(name) is { } part
Psichorex marked this conversation as resolved.
Show resolved Hide resolved
? Initialize<ContentSetContentPickerFieldViewModel>(
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)
: null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,36 +21,47 @@ public class ContentSetPartDisplayDriver : ContentPartDisplayDriver<ContentSetPa
private readonly IContentSetManager _contentSetManager;
private readonly IIdGenerator _idGenerator;
private readonly IEnumerable<IContentSetEventHandler> _contentSetEventHandlers;
private readonly IStringLocalizer<ContentSetPartDisplayDriver> T;
private readonly IStringLocalizer<ContentSetPart> T;

public ContentSetPartDisplayDriver(
IContentSetManager contentSetManager,
IIdGenerator idGenerator,
IEnumerable<IContentSetEventHandler> contentSetEventHandlers,
IStringLocalizer<ContentSetPartDisplayDriver> stringLocalizer)
IStringLocalizer<ContentSetPart> 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))
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<ContentSetPartViewModel>($"{ShapeType}_Tags", InitializeAsync)
.Location(CommonContentDisplayTypes.SummaryAdmin, "Tags:11"),
Initialize<ContentSetPartViewModel>(
$"{ShapeType}_Links",
model => BuildViewModelAsync(model, part, context.TypePartDefinition, isNew: false))
Initialize<ContentSetPartViewModel>($"{ShapeType}_Links", InitializeAsync)
.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))
Initialize<ContentSetPartViewModel>($"{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<IDisplayResult> UpdateAsync(
Expand All @@ -77,56 +85,4 @@ public override async Task<IDisplayResult> 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<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,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
{
}
3 changes: 3 additions & 0 deletions Lombiq.HelpfulExtensions/Extensions/ContentSets/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public override void ConfigureServices(IServiceCollection services)
.WithMigration<Migrations>();

services.AddScoped<IContentSetManager, ContentSetManager>();

services.AddContentField<ContentSetContentPickerField>()
.UseDisplayDriver<ContentSetContentPickerFieldDisplayDriver>();
}

public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using OrchardCore.ContentManagement.Metadata.Models;

namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels;

public class ContentSetContentPickerFieldViewModel : ContentSetPartViewModel
{
public ContentPartFieldDefinition PartFieldDefinition { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -22,6 +28,70 @@ public class ContentSetPartViewModel

[BindNever]
public bool IsNew { get; set; }

[BindNever]
public string DisplayName =>
Definition?
.Settings?
.Property(nameof(ContentTypePartSettings))?
.Value
.ToObject<ContentTypePartSettings>()?
.DisplayName ?? Definition?.Name;

public async ValueTask InitializeAsync(
IContentSetManager contentSetManager,
IEnumerable<IContentSetEventHandler> 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<ContentSetPart>(definition.Name)?.Key);

var options = new Dictionary<string, ContentSetLinkViewModel>
{
[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);
Original file line number Diff line number Diff line change
@@ -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<ContentSetCreatingEvent> stringLocalizer)
: base(stringLocalizer)
{
}
}
Loading