Skip to content

Commit

Permalink
Merge pull request #54 from sarahelsaig/content-editor
Browse files Browse the repository at this point in the history
JSON Content Editor
  • Loading branch information
Piedone authored Dec 20, 2023
2 parents 438073c + 8061dab commit 09d3f06
Show file tree
Hide file tree
Showing 15 changed files with 331 additions and 2 deletions.
Binary file added Docs/actions-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Docs/content-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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}}')]");
Expand All @@ -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();

Expand Down Expand Up @@ -67,6 +68,17 @@ await context.ClickReliablyOnAsync(

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ public static Task ExecuteJsonEditorSampleRecipeDirectlyAsync(this UITestContext

public static Task EnableJsonEditorFeatureAsync(this UITestContext context) =>
context.EnableFeatureDirectlyAsync("Lombiq.JsonEditor");

public static Task EnableJsonContentEditorFeatureAsync(this UITestContext context) =>
context.EnableFeatureDirectlyAsync("Lombiq.JsonEditor.ContentEditor");
}
1 change: 1 addition & 0 deletions Lombiq.JsonEditor/Constants/FeatureIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}";
}
163 changes: 163 additions & 0 deletions Lombiq.JsonEditor/Controllers/AdminController.cs
Original file line number Diff line number Diff line change
@@ -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<ApiController> _contentApiControllerLazy;
private readonly IStringLocalizer<AdminController> T;
private readonly IHtmlLocalizer<AdminController> H;

public AdminController(
IContentDefinitionManager contentDefinitionManager,
ILayoutAccessor layoutAccessor,
INotifier notifier,
IPageTitleBuilder pageTitleBuilder,
IShapeFactory shapeFactory,
IOrchardServices<AdminController> services,
Lazy<ApiController> 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<IActionResult> 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<TitlePartViewModel>("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<IActionResult> 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<ContentItem>(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<bool> CanEditAsync(ContentItem contentItem) =>
_authorizationService.AuthorizeAsync(User, CommonPermissions.EditContent, contentItem);

private async Task<IActionResult> 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}\"";
}
Original file line number Diff line number Diff line change
@@ -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<IDisplayResult> DisplayAsync(ContentItem model, BuildDisplayContext context) =>
await _authorizationService.AuthorizeAsync(_hca.HttpContext?.User, CommonPermissions.EditContent, model)
? Initialize<ContentItemViewModel>("Content_EditJsonActions", viewModel =>
viewModel.ContentItem = model.ContentItem)
.Location("ActionsMenu:after")
: null;
}
2 changes: 2 additions & 0 deletions Lombiq.JsonEditor/Lombiq.JsonEditor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

<ItemGroup>
<None Include="License.md" Pack="true" PackagePath="" />
<None Include="..\Readme.md" Link="Readme.md" />
<None Include="NuGetIcon.png" Pack="true" PackagePath="" />
</ItemGroup>

Expand All @@ -36,6 +37,7 @@

<ItemGroup>
<PackageReference Include="OrchardCore.Module.Targets" Version="1.7.0" />
<PackageReference Include="OrchardCore.Contents" Version="1.7.0" />
<PackageReference Include="OrchardCore.ContentManagement" Version="1.7.0" />
<PackageReference Include="OrchardCore.ContentTypes.Abstractions" Version="1.7.0" />
<PackageReference Include="OrchardCore.DisplayManagement" Version="1.7.0" />
Expand Down
11 changes: 11 additions & 0 deletions Lombiq.JsonEditor/Manifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)]
3 changes: 2 additions & 1 deletion Lombiq.JsonEditor/Recipes/JsonEditor.Sample.recipe.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"name": "feature",
"disable": [],
"enable": [
"Lombiq.JsonEditor"
"Lombiq.JsonEditor",
"Lombiq.JsonEditor.ContentEditor"
]
},
{
Expand Down
32 changes: 32 additions & 0 deletions Lombiq.JsonEditor/Startup.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -23,3 +33,25 @@ public override void ConfigureServices(IServiceCollection services)
services.AddScoped<IContentPartFieldDefinitionDisplayDriver, JsonFieldSettingsDriver>();
}
}

[Feature(FeatureIds.ContentEditor)]
public class ContentEditorStartup : StartupBase
{
private readonly AdminOptions _adminOptions;

public ContentEditorStartup(IOptions<AdminOptions> adminOptions) => _adminOptions = adminOptions.Value;

public override void ConfigureServices(IServiceCollection services)
{
services.AddScoped<IContentDisplayDriver, EditJsonActionsMenuContentDisplayDriver>();
services.AddOrchardServices();
services.AddScoped<ApiController>();
}

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) });
}
9 changes: 9 additions & 0 deletions Lombiq.JsonEditor/ViewModels/EditContentItemViewModel.cs
Original file line number Diff line number Diff line change
@@ -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);
38 changes: 38 additions & 0 deletions Lombiq.JsonEditor/Views/Admin/Edit.cshtml
Original file line number Diff line number Diff line change
@@ -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."]);
}

<p class="alert alert-warning">@warning</p>

<form method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="contentItemId" value="@Model.ContentItem.ContentItemId">
<input type="hidden" name="returnUrl" value="@returnUrl" />

<div class="form-group mb-3">
<json-editor
json="@Model.Json"
options="@JsonEditorOptions.GetSample(T)"
name="json"></json-editor>
</div>

<shape type="Content_PublishButton" />

@if (Model.ContentTypeDefinition.IsDraftable())
{
<shape type="Content_SaveDraftButton" />
}

@if (!string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl))
{
<a class="btn btn-secondary cancel" role="button" href="@returnUrl">@T["Cancel"]</a>
}
</form>
13 changes: 13 additions & 0 deletions Lombiq.JsonEditor/Views/Content_EditJsonActions.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@using OrchardCore.ContentManagement
@using Lombiq.JsonEditor.Controllers

@{
var contentItem = (ContentItem)Model.ContentItem;
}

<a asp-action="@nameof(AdminController.Edit)"
asp-controller="@typeof(AdminController).ControllerName()"
asp-route-area="@FeatureIds.Area"
asp-route-contentItemId="@contentItem.ContentItemId"
asp-route-returnUrl="@FullRequestPath"
class="dropdown-item btn-sm">@T["Edit as JSON"]</a>
Loading

0 comments on commit 09d3f06

Please sign in to comment.