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

JSON Content Editor #54

Merged
merged 34 commits into from
Dec 20, 2023
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
2958e73
Add model, viewmodel and controller.
sarahelsaig Nov 27, 2023
cb6f93a
Post action.
sarahelsaig Nov 27, 2023
3d4bdfe
Rename files and fix route mapping.
sarahelsaig Dec 2, 2023
8eb3c6c
Add actions menu.
sarahelsaig Dec 2, 2023
2321bde
Add title part that works the same way as regular Edit.
sarahelsaig Dec 3, 2023
4587141
Handle returnUrl as expected.
sarahelsaig Dec 3, 2023
9263150
Add UI test to verify content editing.
sarahelsaig Dec 3, 2023
efc865d
Don't use the first item.
sarahelsaig Dec 3, 2023
be3047c
Add documentation in readme.
sarahelsaig Dec 5, 2023
3a9687b
Update Lombiq.JsonEditor/Drivers/EditJsonActionsMenuContentDisplayDri…
sarahelsaig Dec 5, 2023
40665db
Fix readme.
sarahelsaig Dec 5, 2023
0cf11d4
Add warning note.
sarahelsaig Dec 5, 2023
df79fd3
Make the title show up.
sarahelsaig Dec 5, 2023
3735468
Menu item only if authorized.
sarahelsaig Dec 5, 2023
daf2434
Split into separate feature.
sarahelsaig Dec 5, 2023
e476a59
Only if feature is enabled.
sarahelsaig Dec 5, 2023
0f34c5e
Ensure content is published.
sarahelsaig Dec 5, 2023
9879110
Generate new version ID.
sarahelsaig Dec 5, 2023
d8c4f83
Better title text.
sarahelsaig Dec 5, 2023
d943930
Remove unnecessary line.
sarahelsaig Dec 5, 2023
c00d589
call LoadAsync
sarahelsaig Dec 8, 2023
9e9287f
Clean up call order.
sarahelsaig Dec 8, 2023
979afa5
Use ApiController.Post to update the content item.
sarahelsaig Dec 8, 2023
a8424da
Add draft feature.
sarahelsaig Dec 8, 2023
25c2106
Don't fail if the user doesn't have AccessContentApi permission.
sarahelsaig Dec 8, 2023
156e741
Merge branch 'Lombiq:dev' into content-editor
sarahelsaig Dec 8, 2023
7e95d84
Bug fix.
sarahelsaig Dec 8, 2023
79490b9
Merge branch 'content-editor' of https://github.com/sarahelsaig/Orcha…
sarahelsaig Dec 8, 2023
bbfceff
Apply suggestions from code review
sarahelsaig Dec 12, 2023
aaf6574
Not needed.
sarahelsaig Dec 12, 2023
a4c119e
Inject the API controller.
sarahelsaig Dec 12, 2023
427b7f9
Add comments about AdminController.UpdateContentAsync.
sarahelsaig Dec 12, 2023
7fe7193
Code styling.
sarahelsaig Dec 12, 2023
8061dab
Typos
Piedone Dec 20, 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
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 @@ 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
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
Piedone marked this conversation as resolved.
Show resolved Hide resolved
Piedone marked this conversation as resolved.
Show resolved Hide resolved
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 =>
Piedone marked this conversation as resolved.
Show resolved Hide resolved
{
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
Piedone marked this conversation as resolved.
Show resolved Hide resolved
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 a
// HTTP client (except the permission bypass above), but it's faster and more resource efficient.
Piedone marked this conversation as resolved.
Show resolved Hide resolved
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(
Piedone marked this conversation as resolved.
Show resolved Hide resolved
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>
Piedone marked this conversation as resolved.
Show resolved Hide resolved
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)"
Piedone marked this conversation as resolved.
Show resolved Hide resolved
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