diff --git a/Lombiq.HelpfulExtensions/Controllers/ContentSetController.cs b/Lombiq.HelpfulExtensions/Controllers/ContentSetController.cs new file mode 100644 index 00000000..03b929c7 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Controllers/ContentSetController.cs @@ -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 Create(string fromContentItemId, string fromPartName, string newKey) => + await _contentSetManager.CloneContentItemAsync(fromContentItemId, fromPartName, newKey) is { } content + ? Redirect(_orchardHelper.GetItemEditUrl(content)) + : NotFound(); +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetPartDisplayDriver.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetPartDisplayDriver.cs new file mode 100644 index 00000000..90f80478 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Drivers/ContentSetPartDisplayDriver.cs @@ -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 +{ + private const string ShapeType = $"{nameof(ContentSetPart)}_{CommonContentDisplayTypes.SummaryAdmin}"; + + private readonly IContentSetManager _contentSetManager; + private readonly IIdGenerator _idGenerator; + private readonly IEnumerable _contentSetEventHandlers; + private readonly IStringLocalizer T; + + public ContentSetPartDisplayDriver( + IContentSetManager contentSetManager, + IIdGenerator idGenerator, + IEnumerable contentSetEventHandlers, + IStringLocalizer stringLocalizer) + { + _contentSetManager = contentSetManager; + _idGenerator = idGenerator; + _contentSetEventHandlers = contentSetEventHandlers; + T = stringLocalizer; + } + + public override IDisplayResult Display(ContentSetPart part, BuildPartDisplayContext context) => + Combine( + Initialize( + $"{ShapeType}_Tags", + model => BuildViewModelAsync(model, part, context.TypePartDefinition, isNew: false)) + .Location(CommonContentDisplayTypes.SummaryAdmin, "Tags:11"), + Initialize( + $"{ShapeType}_Links", + model => BuildViewModelAsync(model, part, context.TypePartDefinition, isNew: false)) + .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)) + .Location($"Parts:0%{context.TypePartDefinition.Name};0"); + + public override async Task 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(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/Events/IContentSetEventHandler.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Events/IContentSetEventHandler.cs new file mode 100644 index 00000000..7f17b8d9 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Events/IContentSetEventHandler.cs @@ -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; + +/// +/// Events relating to containing content items. +/// +public interface IContentSetEventHandler +{ + /// + /// Returns the available items relating to the content item that contains the . This can be + /// used for a dropdown to access the other contents in the set. + /// + /// + /// A collection of option links, or if this even handler is not applicable for the . + /// + Task> GetSupportedOptionsAsync( + ContentSetPart part, + ContentTypePartDefinition definition) => + Task.FromResult>(null); + + /// + /// The event triggered when a donor content item is cloned but before it's published. + /// + /// The new content item. + /// + /// The part definition indicating which is responsible for this event. + /// + /// The unique ID of the content set. + /// The new item's key, which is unique within the content set. + Task CreatingAsync( + ContentItem content, + ContentTypePartDefinition definition, + string contentSet, + string newKey) => + Task.CompletedTask; +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Indexes/ContentSetIndex.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Indexes/ContentSetIndex.cs new file mode 100644 index 00000000..b03fe52b --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Indexes/ContentSetIndex.cs @@ -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 +{ + private readonly IServiceProvider _provider; + + // We can't inject Lazy 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 context) => + context.For().Map(contentItem => + { + if (!contentItem.Latest) return Enumerable.Empty(); + + using var scope = _provider.CreateScope(); + var contentDefinitionManager = scope.ServiceProvider.GetRequiredService(); + + return contentDefinitionManager + .GetTypeDefinition(contentItem.ContentType) + .Parts + .Where(part => part.PartDefinition.Name == nameof(ContentSetPart)) + .Select(part => new { Part = contentItem.Get(part.Name), part.Name }) + .Where(info => info.Part != null) + .Select(info => ContentSetIndex.FromPart(info.Part, info.Name)) + .Where(index => index.ContentSet != null); + }); +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Migrations.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Migrations.cs new file mode 100644 index 00000000..1c8364a7 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Migrations.cs @@ -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(table => table + .Column(nameof(ContentSetIndex.ContentItemId), column => column.WithCommonUniqueIdLength()) + .Column(nameof(ContentSetIndex.PartName)) + .Column(nameof(ContentSetIndex.IsPublished)) + .Column(nameof(ContentSetIndex.ContentSet)) + .Column(nameof(ContentSetIndex.Key))); + + return 1; + } +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Models/ContentSetPart.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Models/ContentSetPart.cs new file mode 100644 index 00000000..d5bd47ae --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Models/ContentSetPart.cs @@ -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; +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Services/ContentSetManager.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Services/ContentSetManager.cs new file mode 100644 index 00000000..5145175d --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Services/ContentSetManager.cs @@ -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 _contentSetEventHandlers; + private readonly ISession _session; + + public ContentSetManager( + IContentDefinitionManager contentDefinitionManager, + IContentManager contentManager, + IEnumerable contentSetEventHandlers, + ISession session) + { + _contentDefinitionManager = contentDefinitionManager; + _contentManager = contentManager; + _contentSetEventHandlers = contentSetEventHandlers; + _session = session; + } + + public Task> GetIndexAsync(string setId) => + _session.QueryIndex(index => index.ContentSet == setId).ListAsync(); + + public async Task> GetContentItemsAsync(string setId) => + await _contentManager.GetAsync((await GetIndexAsync(setId)).Select(index => index.ContentItemId)); + + public async Task CloneContentItemAsync(string fromContentItemId, string fromPartName, string newKey) + { + if (string.IsNullOrEmpty(fromPartName)) fromPartName = nameof(ContentSetPart); + + if (await _contentManager.GetAsync(fromContentItemId) is not { } original || + original.Get(fromPartName)?.ContentSet is not { } contentSet || + await _contentManager.CloneAsync(original) is not { } content) + { + return null; + } + + var exists = await _session + .QueryIndex(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(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; + } +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Services/IContentSetManager.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Services/IContentSetManager.cs new file mode 100644 index 00000000..ff95f6a6 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Services/IContentSetManager.cs @@ -0,0 +1,35 @@ +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Indexes; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; +using OrchardCore.ContentManagement; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.Services; + +/// +/// A service for getting and setting content sets. +/// +public interface IContentSetManager +{ + /// + /// Returns all entries for the content set with the given . + /// + Task> GetIndexAsync(string setId); + + /// + /// Returns all content items in a content set with the given . + /// + Task> GetContentItemsAsync(string setId); + + /// + /// Creates and publishes a new content item in the same content set by cloning the one with the given ID. + /// + /// The content item ID of the content to be cloned. + /// + /// The name of the whose set has to be amended. If it's not a named content part, then + /// it can be . + /// + /// The new key of the content set item, must be unique within the set.. + /// The newly created content item, or if the source was not found. + Task CloneContentItemAsync(string fromContentItemId, string fromPartName, string newKey); +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/Startup.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Startup.cs new file mode 100644 index 00000000..4a5f3d97 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/Startup.cs @@ -0,0 +1,33 @@ +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Drivers; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Indexes; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Display.ContentDisplay; +using OrchardCore.Modules; +using System; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets; + +[Feature(FeatureIds.ContentSets)] +public class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services + .AddContentPart() + .UseDisplayDriver() + .WithIndex() + .WithMigration(); + + services.AddScoped(); + } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + // No need for anything here yet. + } +} diff --git a/Lombiq.HelpfulExtensions/Extensions/ContentSets/ViewModels/ContentSetPartViewModel.cs b/Lombiq.HelpfulExtensions/Extensions/ContentSets/ViewModels/ContentSetPartViewModel.cs new file mode 100644 index 00000000..f97b3cbb --- /dev/null +++ b/Lombiq.HelpfulExtensions/Extensions/ContentSets/ViewModels/ContentSetPartViewModel.cs @@ -0,0 +1,27 @@ +using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using OrchardCore.ContentManagement.Metadata.Models; +using System.Collections.Generic; +using System.Linq; + +namespace Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels; + +public class ContentSetPartViewModel +{ + public string ContentSet { get; set; } + public string Key { get; set; } + + [BindNever] + public ContentTypePartDefinition Definition { get; set; } + + [BindNever] + public ContentSetPart ContentSetPart { get; set; } + + [BindNever] + public IEnumerable MemberLinks { get; set; } = Enumerable.Empty(); + + [BindNever] + public bool IsNew { get; set; } +} + +public record ContentSetLinkViewModel(bool IsDeleted, string DisplayText, string ContentItemId, string Key); diff --git a/Lombiq.HelpfulExtensions/FeatureIds.cs b/Lombiq.HelpfulExtensions/FeatureIds.cs index ff5e584f..3cd3b3ee 100644 --- a/Lombiq.HelpfulExtensions/FeatureIds.cs +++ b/Lombiq.HelpfulExtensions/FeatureIds.cs @@ -6,6 +6,7 @@ public static class FeatureIds private const string FeatureIdPrefix = Base + "."; public const string CodeGeneration = FeatureIdPrefix + nameof(CodeGeneration); + public const string ContentSets = FeatureIdPrefix + nameof(ContentSets); public const string ContentTypes = FeatureIdPrefix + nameof(ContentTypes); public const string Flows = FeatureIdPrefix + nameof(Flows); public const string ShapeTracing = FeatureIdPrefix + nameof(ShapeTracing); diff --git a/Lombiq.HelpfulExtensions/Manifest.cs b/Lombiq.HelpfulExtensions/Manifest.cs index b2b6d021..b59ef142 100644 --- a/Lombiq.HelpfulExtensions/Manifest.cs +++ b/Lombiq.HelpfulExtensions/Manifest.cs @@ -19,6 +19,17 @@ } )] +[assembly: Feature( + Id = ContentSets, + Name = "Lombiq Helpful Extensions - Content Sets", + Category = "Development", + Description = "Create arbitrary collections of content items.", + Dependencies = new[] + { + "OrchardCore.ContentManagement", + } +)] + [assembly: Feature( Id = Flows, Name = "Lombiq Helpful Extensions - Flows Helpful Extensions", diff --git a/Lombiq.HelpfulExtensions/Views/ContentSetPart.Edit.cshtml b/Lombiq.HelpfulExtensions/Views/ContentSetPart.Edit.cshtml new file mode 100644 index 00000000..9fb2805b --- /dev/null +++ b/Lombiq.HelpfulExtensions/Views/ContentSetPart.Edit.cshtml @@ -0,0 +1,21 @@ +@model Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels.ContentSetPartViewModel + +

@T["Information about the \"{0}\" content set part.", Model.Definition.Name]

+ +
+ +@if (!Model.IsNew) +{ +
+ +
    + @foreach (var link in Model.MemberLinks) + { + + } +
+
+} diff --git a/Lombiq.HelpfulExtensions/Views/ContentSetPart.MemberLink.cshtml b/Lombiq.HelpfulExtensions/Views/ContentSetPart.MemberLink.cshtml new file mode 100644 index 00000000..154a5d59 --- /dev/null +++ b/Lombiq.HelpfulExtensions/Views/ContentSetPart.MemberLink.cshtml @@ -0,0 +1,32 @@ +@using OrchardCore +@using OrchardCore.ContentManagement.Metadata.Models +@using OrchardCore.Contents.Controllers +@using Lombiq.HelpfulExtensions.Extensions.ContentSets.Controllers +@using Lombiq.HelpfulExtensions.Extensions.ContentSets.Models +@using Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels +@{ + if (Model.Link is not ContentSetLinkViewModel link) { return; } + var contentSetPart = Model.ContentSetPart as ContentSetPart; + var definition = Model.Definition as ContentTypePartDefinition; +} + +
  • + @if (!string.IsNullOrEmpty(link.ContentItemId)) + { + var url = Orchard.Action(controller => controller.Edit( + link.ContentItemId)); + + @link.DisplayText + + } + else + { + var url = Orchard.Action(controller => controller.Create( + 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 new file mode 100644 index 00000000..538a906b --- /dev/null +++ b/Lombiq.HelpfulExtensions/Views/ContentSetPart.SummaryAdmin.Links.cshtml @@ -0,0 +1,26 @@ +@model Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels.ContentSetPartViewModel + +@{ + if (string.IsNullOrEmpty(Model.ContentSet) || !Model.MemberLinks.Any()) { return; } + + var title = T[Model.Definition.Name]; +} + +
    + + +
    diff --git a/Lombiq.HelpfulExtensions/Views/ContentSetPart.SummaryAdmin.Tags.cshtml b/Lombiq.HelpfulExtensions/Views/ContentSetPart.SummaryAdmin.Tags.cshtml new file mode 100644 index 00000000..1f35651d --- /dev/null +++ b/Lombiq.HelpfulExtensions/Views/ContentSetPart.SummaryAdmin.Tags.cshtml @@ -0,0 +1,9 @@ +@model Lombiq.HelpfulExtensions.Extensions.ContentSets.ViewModels.ContentSetPartViewModel + +@if (Model.ContentSetPart.Key != null) +{ + + + @T[Model.Definition.Name]: @Model.ContentSetPart.Key + +}