diff --git a/Docs/actions-menu.png b/Docs/actions-menu.png new file mode 100644 index 0000000..6d456de Binary files /dev/null and b/Docs/actions-menu.png differ diff --git a/Docs/content-editor.png b/Docs/content-editor.png new file mode 100644 index 0000000..77cd5f1 Binary files /dev/null and b/Docs/content-editor.png differ diff --git a/Lombiq.JsonEditor.Test.UI/Extensions/TestCaseUITestContextExtensions.cs b/Lombiq.JsonEditor.Test.UI/Extensions/TestCaseUITestContextExtensions.cs index 9a3bad5..3655c33 100644 --- a/Lombiq.JsonEditor.Test.UI/Extensions/TestCaseUITestContextExtensions.cs +++ b/Lombiq.JsonEditor.Test.UI/Extensions/TestCaseUITestContextExtensions.cs @@ -15,6 +15,7 @@ public static class TestCaseUITestContextExtensions private const string WorldValue = "world"; private const string TestField = "testField"; private const string TestValue = "testValue"; + private const string TestAuthor = "Custom Test Author"; private static readonly By ObjectByXPath = By.XPath($"//div[@class='jsoneditor-readonly' and contains(text(),'object')]"); private static readonly By ObjectCountByXPath = By.XPath($"//div[@class='jsoneditor-value jsoneditor-object' and contains(text(),'{{2}}')]"); @@ -24,7 +25,7 @@ public static class TestCaseUITestContextExtensions public static async Task TestJsonEditorBehaviorAsync(this UITestContext context) { - await context.EnableJsonEditorFeatureAsync(); + await context.EnableJsonContentEditorFeatureAsync(); await context.ExecuteJsonEditorSampleRecipeDirectlyAsync(); @@ -67,6 +68,17 @@ public static async Task TestJsonEditorBehaviorAsync(this UITestContext context) await context.SwitchToModeAsync("Preview"); context.TestCodeStyleMode(); + + // Test that content JSON editing works. + await context.GoToContentItemListAsync(); + await context.SelectFromBootstrapDropdownReliablyAsync( + By.CssSelector(".list-group-item:nth-child(3) .dropdown-toggle.actions"), + "Edit as JSON"); + context + .Get(By.XPath("//div[contains(@class, 'jsoneditor-field') and contains(., 'Author')]/../..//div[contains(@class, 'jsoneditor-value')]")) + .FillInWith(TestAuthor); + await context.ClickPublishAsync(); + context.Exists(By.CssSelector(".ta-badge[data-bs-original-title='Author']")); } private static void CheckValueInTreeMode(this UITestContext context, string arrayValue, bool exists = true) diff --git a/Lombiq.JsonEditor.Test.UI/Extensions/UITestContextExtensions.cs b/Lombiq.JsonEditor.Test.UI/Extensions/UITestContextExtensions.cs index 37560eb..4c5752e 100644 --- a/Lombiq.JsonEditor.Test.UI/Extensions/UITestContextExtensions.cs +++ b/Lombiq.JsonEditor.Test.UI/Extensions/UITestContextExtensions.cs @@ -11,4 +11,7 @@ public static class UITestContextExtensions public static Task EnableJsonEditorFeatureAsync(this UITestContext context) => context.EnableFeatureDirectlyAsync("Lombiq.JsonEditor"); + + public static Task EnableJsonContentEditorFeatureAsync(this UITestContext context) => + context.EnableFeatureDirectlyAsync("Lombiq.JsonEditor.ContentEditor"); } diff --git a/Lombiq.JsonEditor/Constants/FeatureIds.cs b/Lombiq.JsonEditor/Constants/FeatureIds.cs index 84da693..19181ad 100644 --- a/Lombiq.JsonEditor/Constants/FeatureIds.cs +++ b/Lombiq.JsonEditor/Constants/FeatureIds.cs @@ -5,4 +5,5 @@ public static class FeatureIds public const string Area = "Lombiq.JsonEditor"; public const string Default = Area; + public const string ContentEditor = $"{Area}.{nameof(ContentEditor)}"; } diff --git a/Lombiq.JsonEditor/Controllers/AdminController.cs b/Lombiq.JsonEditor/Controllers/AdminController.cs new file mode 100644 index 0000000..ed8b29b --- /dev/null +++ b/Lombiq.JsonEditor/Controllers/AdminController.cs @@ -0,0 +1,163 @@ +using AngleSharp.Common; +using Lombiq.HelpfulLibraries.OrchardCore.Contents; +using Lombiq.HelpfulLibraries.OrchardCore.DependencyInjection; +using Lombiq.JsonEditor.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Localization; +using Newtonsoft.Json; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Metadata; +using OrchardCore.Contents; +using OrchardCore.Contents.Controllers; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.Layout; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.DisplayManagement.Title; +using OrchardCore.Title.ViewModels; +using System; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Lombiq.JsonEditor.Controllers; + +public class AdminController : Controller +{ + private readonly IAuthorizationService _authorizationService; + private readonly IContentManager _contentManager; + private readonly IContentDefinitionManager _contentDefinitionManager; + private readonly ILayoutAccessor _layoutAccessor; + private readonly INotifier _notifier; + private readonly IPageTitleBuilder _pageTitleBuilder; + private readonly IShapeFactory _shapeFactory; + private readonly Lazy _contentApiControllerLazy; + private readonly IStringLocalizer T; + private readonly IHtmlLocalizer H; + + public AdminController( + IContentDefinitionManager contentDefinitionManager, + ILayoutAccessor layoutAccessor, + INotifier notifier, + IPageTitleBuilder pageTitleBuilder, + IShapeFactory shapeFactory, + IOrchardServices services, + Lazy contentApiControllerLazy) + { + _authorizationService = services.AuthorizationService.Value; + _contentManager = services.ContentManager.Value; + _contentDefinitionManager = contentDefinitionManager; + _layoutAccessor = layoutAccessor; + _notifier = notifier; + _pageTitleBuilder = pageTitleBuilder; + _shapeFactory = shapeFactory; + _contentApiControllerLazy = contentApiControllerLazy; + T = services.StringLocalizer.Value; + H = services.HtmlLocalizer.Value; + } + + public async Task Edit(string contentItemId) + { + if (string.IsNullOrWhiteSpace(contentItemId) || + await _contentManager.GetAsync(contentItemId, VersionOptions.Latest) is not { } contentItem || + !await CanEditAsync(contentItem)) + { + return NotFound(); + } + + var title = T["Edit {0} as JSON", GetName(contentItem)].Value; + _pageTitleBuilder.AddSegment(new StringHtmlContent(title)); + var titleShape = await _shapeFactory.CreateAsync("TitlePart", model => + { + model.Title = title; + model.ContentItem = contentItem; + }); + await _layoutAccessor.AddShapeToZoneAsync("Title", titleShape); + + var definition = _contentDefinitionManager.GetTypeDefinition(contentItem.ContentType); + return View(new EditContentItemViewModel(contentItem, definition, JsonConvert.SerializeObject(contentItem))); + } + + [ValidateAntiForgeryToken] + [HttpPost, ActionName(nameof(Edit))] + public async Task EditPost( + string contentItemId, + string json, + string returnUrl, + [Bind(Prefix = "submit.Publish")] string submitPublish, + [Bind(Prefix = "submit.Save")] string submitSave) + { + if (string.IsNullOrWhiteSpace(contentItemId) || + string.IsNullOrWhiteSpace(json) || + JsonConvert.DeserializeObject(json) is not { } contentItem) + { + return NotFound(); + } + + if (string.IsNullOrWhiteSpace(contentItem.ContentItemId)) contentItem.ContentItemId = contentItemId; + contentItem = await _contentManager.LoadAsync(contentItem); + + if (!await CanEditAsync(contentItem)) + { + return NotFound(); + } + + switch (await UpdateContentAsync(contentItem, submitSave != null)) + { + case BadRequestObjectResult { Value: ValidationProblemDetails details } + when !string.IsNullOrWhiteSpace(details.Detail): + await _notifier.ErrorAsync(new LocalizedHtmlString(details.Detail, details.Detail)); + return await Edit(contentItem.ContentItemId); + case OkObjectResult: + await _notifier.SuccessAsync(H["Content item {0} has been successfully saved.", GetName(contentItem)]); + break; + default: + await _notifier.ErrorAsync(H["The submission has failed, please try again."]); + return await Edit(contentItem.ContentItemId); + } + + if (!string.IsNullOrEmpty(returnUrl) && + !(IsContinue(submitSave) || IsContinue(submitPublish)) && + Url.IsLocalUrl(returnUrl)) + { + return Redirect(returnUrl); + } + + return RedirectToAction(nameof(Edit), new { contentItemId, returnUrl }); + } + + private Task CanEditAsync(ContentItem contentItem) => + _authorizationService.AuthorizeAsync(User, CommonPermissions.EditContent, contentItem); + + private async Task UpdateContentAsync(ContentItem contentItem, bool isDraft) + { + // The Content API Controller requires the AccessContentApi permission. As this isn't an external API request it + // doesn't make sense to require this permission. So we create a temporary claims principal and explicitly grant + // the permission. + var currentUser = User; + HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(User.Claims.Concat(Permissions.AccessContentApi))); + + try + { + // Here the API controller is called directly. The behavior is the same as if we sent a POST request using an + // HTTP client (except the permission bypass above), but it's faster and more resource-efficient. + var contentApiController = _contentApiControllerLazy.Value; + contentApiController.ControllerContext.HttpContext = HttpContext; + return await contentApiController.Post(contentItem, isDraft); + } + finally + { + // Ensure that the original claims principal is restored, just in case. + HttpContext.User = currentUser; + } + } + + private static bool IsContinue(string submitString) => + submitString?.EndsWithOrdinalIgnoreCase("AndContinue") == true; + + private static string GetName(ContentItem contentItem) => + string.IsNullOrWhiteSpace(contentItem.DisplayText) + ? contentItem.ContentType + : $"\"{contentItem.DisplayText}\""; +} diff --git a/Lombiq.JsonEditor/Drivers/EditJsonActionsMenuContentDisplayDriver.cs b/Lombiq.JsonEditor/Drivers/EditJsonActionsMenuContentDisplayDriver.cs new file mode 100644 index 0000000..e744842 --- /dev/null +++ b/Lombiq.JsonEditor/Drivers/EditJsonActionsMenuContentDisplayDriver.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Display.ContentDisplay; +using OrchardCore.ContentManagement.Display.ViewModels; +using OrchardCore.Contents; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using System.Threading.Tasks; + +namespace Lombiq.JsonEditor.Drivers; + +public class EditJsonActionsMenuContentDisplayDriver : ContentDisplayDriver +{ + private readonly IAuthorizationService _authorizationService; + private readonly IHttpContextAccessor _hca; + + public EditJsonActionsMenuContentDisplayDriver(IAuthorizationService authorizationService, IHttpContextAccessor hca) + { + _authorizationService = authorizationService; + _hca = hca; + } + + public override async Task DisplayAsync(ContentItem model, BuildDisplayContext context) => + await _authorizationService.AuthorizeAsync(_hca.HttpContext?.User, CommonPermissions.EditContent, model) + ? Initialize("Content_EditJsonActions", viewModel => + viewModel.ContentItem = model.ContentItem) + .Location("ActionsMenu:after") + : null; +} diff --git a/Lombiq.JsonEditor/Lombiq.JsonEditor.csproj b/Lombiq.JsonEditor/Lombiq.JsonEditor.csproj index 39adec2..d737fa5 100644 --- a/Lombiq.JsonEditor/Lombiq.JsonEditor.csproj +++ b/Lombiq.JsonEditor/Lombiq.JsonEditor.csproj @@ -22,6 +22,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/Lombiq.JsonEditor/Manifest.cs b/Lombiq.JsonEditor/Manifest.cs index 6f6e3b6..a2704cf 100644 --- a/Lombiq.JsonEditor/Manifest.cs +++ b/Lombiq.JsonEditor/Manifest.cs @@ -20,3 +20,14 @@ "OrchardCore.ResourceManagement", } )] + +[assembly: Feature( + Id = ContentEditor, + Name = "Lombiq JSON Content Editor", + Category = "Content", + Description = "Adds an actions menu item to the content item list for editing them as JSON.", + Dependencies = new[] + { + Default, + } +)] diff --git a/Lombiq.JsonEditor/Recipes/JsonEditor.Sample.recipe.json b/Lombiq.JsonEditor/Recipes/JsonEditor.Sample.recipe.json index 4a4a4bf..171c3ab 100644 --- a/Lombiq.JsonEditor/Recipes/JsonEditor.Sample.recipe.json +++ b/Lombiq.JsonEditor/Recipes/JsonEditor.Sample.recipe.json @@ -19,7 +19,8 @@ "name": "feature", "disable": [], "enable": [ - "Lombiq.JsonEditor" + "Lombiq.JsonEditor", + "Lombiq.JsonEditor.ContentEditor" ] }, { diff --git a/Lombiq.JsonEditor/Startup.cs b/Lombiq.JsonEditor/Startup.cs index ee9fe32..98ea666 100644 --- a/Lombiq.JsonEditor/Startup.cs +++ b/Lombiq.JsonEditor/Startup.cs @@ -1,14 +1,24 @@ +using Lombiq.HelpfulLibraries.OrchardCore.DependencyInjection; +using Lombiq.JsonEditor.Constants; using Lombiq.JsonEditor.Drivers; using Lombiq.JsonEditor.Fields; using Lombiq.JsonEditor.Settings; using Lombiq.JsonEditor.TagHelpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using OrchardCore.Admin; using OrchardCore.ContentManagement; using OrchardCore.ContentManagement.Display.ContentDisplay; +using OrchardCore.Contents.Controllers; using OrchardCore.ContentTypes.Editors; using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; using OrchardCore.ResourceManagement; +using System; + +using AdminController = Lombiq.JsonEditor.Controllers.AdminController; namespace Lombiq.JsonEditor; @@ -23,3 +33,25 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); } } + +[Feature(FeatureIds.ContentEditor)] +public class ContentEditorStartup : StartupBase +{ + private readonly AdminOptions _adminOptions; + + public ContentEditorStartup(IOptions adminOptions) => _adminOptions = adminOptions.Value; + + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddOrchardServices(); + services.AddScoped(); + } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) => + routes.MapAreaControllerRoute( + name: "EditContentItem", + areaName: FeatureIds.Area, + pattern: _adminOptions.AdminUrlPrefix + "/Contents/ContentItems/{contentItemId}/Edit/Json", + defaults: new { controller = typeof(AdminController).ControllerName(), action = nameof(AdminController.Edit) }); +} diff --git a/Lombiq.JsonEditor/ViewModels/EditContentItemViewModel.cs b/Lombiq.JsonEditor/ViewModels/EditContentItemViewModel.cs new file mode 100644 index 0000000..e369ff1 --- /dev/null +++ b/Lombiq.JsonEditor/ViewModels/EditContentItemViewModel.cs @@ -0,0 +1,9 @@ +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Metadata.Models; + +namespace Lombiq.JsonEditor.ViewModels; + +public record EditContentItemViewModel( + ContentItem ContentItem, + ContentTypeDefinition ContentTypeDefinition, + string Json); diff --git a/Lombiq.JsonEditor/Views/Admin/Edit.cshtml b/Lombiq.JsonEditor/Views/Admin/Edit.cshtml new file mode 100644 index 0000000..616af89 --- /dev/null +++ b/Lombiq.JsonEditor/Views/Admin/Edit.cshtml @@ -0,0 +1,38 @@ +@using Microsoft.AspNetCore.Html +@using OrchardCore.ContentManagement.Metadata.Models +@model Lombiq.JsonEditor.ViewModels.EditContentItemViewModel + +@{ + var returnUrl = Context.Request.Query["returnUrl"]; + + var warning = new HtmlString(" ").Join( + T["Be careful while editing a content item as any typo can lead to a loss of functionality."], + T["The submitted JSON will be deserialized and published so properties may be altered or regenerated at that step."]); +} + +

@warning

+ +
+ @Html.AntiForgeryToken() + + + +
+ +
+ + + + @if (Model.ContentTypeDefinition.IsDraftable()) + { + + } + + @if (!string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl)) + { + @T["Cancel"] + } + diff --git a/Lombiq.JsonEditor/Views/Content_EditJsonActions.cshtml b/Lombiq.JsonEditor/Views/Content_EditJsonActions.cshtml new file mode 100644 index 0000000..4a457dd --- /dev/null +++ b/Lombiq.JsonEditor/Views/Content_EditJsonActions.cshtml @@ -0,0 +1,13 @@ +@using OrchardCore.ContentManagement +@using Lombiq.JsonEditor.Controllers + +@{ + var contentItem = (ContentItem)Model.ContentItem; +} + +@T["Edit as JSON"] \ No newline at end of file diff --git a/Readme.md b/Readme.md index 8d2bcd7..5cff5b2 100644 --- a/Readme.md +++ b/Readme.md @@ -10,6 +10,8 @@ Do you want to quickly try out this project and see it in action? Check it out i ## Documentation +### JSON Editor + You can use the JSON editor either as a content field by adding a _Json Field_ to your content type, or by invoking the "JsonEditor" shape with the below tag helper: ```html @@ -37,6 +39,18 @@ The properties are: All attributes are optional. If neither content nor json is set, an empty object is taken as the content. +### JSON Content Editor + +The module also provides an editor for content items. This can be used to directly edit a content item as JSON data. This tool can be useful to inspect how the content item is serialized in the YesSql database without directly accessing the database or exporting the content item via deployment. It can also be used to edit properties that currently don't have an editor. + +When the module is enabled, a new _Edit as JSON_ entry is added to the actions dropdown in the admin dashboard's content item listing: +![actions menu](Docs/actions-menu.png) + +Clicking on it encodes the content item as JSON and displays it in the JSON editor: +![JSON content editor](Docs/content-editor.png) + +This still requires edit permission to the content item, so the security is the same as the regular content item editor. Clicking _Publish_ deserializes the received JSON into a ContentItem and publishes it. + ## Dependencies This module has the following dependencies: