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 8 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
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 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
105 changes: 105 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,105 @@
using Lombiq.HelpfulLibraries.OrchardCore.Contents;
using Lombiq.JsonEditor.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;
using OrchardCore.ContentManagement;
using OrchardCore.Contents;
using OrchardCore.DisplayManagement;
using OrchardCore.DisplayManagement.Layout;
using OrchardCore.Title.ViewModels;
using System.Threading.Tasks;
using YesSql;

namespace Lombiq.JsonEditor.Controllers;

public class AdminController : Controller
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentManager _contentManager;
private readonly ILayoutAccessor _layoutAccessor;
private readonly ISession _session;
private readonly IShapeFactory _shapeFactory;
private readonly IStringLocalizer<AdminController> T;

public AdminController(
IAuthorizationService authorizationService,
IContentManager contentManager,
ILayoutAccessor layoutAccessor,
ISession session,
IShapeFactory shapeFactory,
IStringLocalizer<AdminController> stringLocalizer)
{
_authorizationService = authorizationService;
_contentManager = contentManager;
_layoutAccessor = layoutAccessor;
_session = session;
_shapeFactory = shapeFactory;
T = stringLocalizer;
}

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 titleShape = await _shapeFactory.CreateAsync<TitlePartViewModel>("TitlePart", model =>
Piedone marked this conversation as resolved.
Show resolved Hide resolved
{
model.Title = T["Edit {0} as JSON", contentItem.ContentType];
Piedone marked this conversation as resolved.
Show resolved Hide resolved
model.ContentItem = contentItem;
});
await _layoutAccessor.AddShapeToZoneAsync("Title", titleShape);

return View(new EditContentItemViewModel(contentItem, 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)
{
if (string.IsNullOrWhiteSpace(contentItemId) ||
string.IsNullOrWhiteSpace(json) ||
JsonConvert.DeserializeObject<ContentItem>(json) is not { } contentItem)
{
return NotFound();
}

if (string.IsNullOrWhiteSpace(contentItem.ContentItemId)) contentItem.ContentItemId = contentItemId;
if (!await CanEditAsync(contentItem))
{
return NotFound();
}

if (await _contentManager.GetAsync(contentItem.ContentItemId, VersionOptions.Latest) is { } existing)
{
existing.Latest = false;
existing.Published = false;
_session.Save(existing);
contentItem.ContentItemVersionId = null;
}

await _contentManager.PublishAsync(contentItem);
_session.Save(contentItem);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something similar should happen with this too:

existing = await _contentManager.GetAsync(contentItem.ContentItemId, VersionOptions.DraftRequired);
...
await _contentManager.PublishAsync(contentItem);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not good. If the content is versionable, this creates a new draft clone of the content item we have no use for. We don't want that, because the new version's content should come from the JSON, not through cloning the existing item.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the content is versionable, we need to edit a new or existing draft, not the latest version, what might be a published one. Unless you want to let people circumvent versioning, what I wouldn't do.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not editing the latest version. We are unpublishing it and the publishing a new content item that's deserialized from the received JSON.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this necessary instead of utilizing Orchard's versioning?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for info and from memory

  • With STJ we did a JConvert helper to serialize /deserialize a content item as before.

    JConvert.DeserializeObject<ContentItem>(json)
    
  • Yes the Version column is not related to the ContentItemVersionId, it is used when we save an item with checkConcurrency: true, it throws if a given record is written concurrently.

  • Yes this is not obvious to call the right content manager methods and in the right order ;)

    First I assume that the editor doesn't allow to modify some data that are still managed internally, like ContentItemId, ContentItemVersionId, Latest, Published and so on. And I saw that from the editor we only can publish, not save as a draft.

    Why not if it is an advanced feature intended to act directly on a given version record, but yes here we don't follow the version management pattern, e.g. loading / activating a new version if the type is Versionable. Yes, the Latest may not be Published, hmm but if this is the one we are editing it will be, if an item is already Published, PublishAsync() does nothing, that why I think you needed to call _session.Save().

    Okay there is no driver validations but as I can see no updating and validating handlers will be called too. So what I suggest is to follow the pattern used in the ApiController.Post of the OC.Contents module.

    It requests to load and activate a DraftRequired, a new version is built if the type is Versionable, the result being unpublished, if it doesn't exists here you can return NotFound, then it merges this existing item version with the provided one, here can be deserialized from the editor, then it calls UpdateAsync() and ValidateAsync() that calls validating handlers, finally it does SaveDraftAsync() or as here PublishAsync() (here the item is not already published) that may unpublish a previous version.

    The whole without having to call any _session.Save().

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for chiming in, JT. Reopening this convo, then.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With STJ we did a JConvert helper to serialize /deserialize a content item as before.

I see it in your branch but it's not in OC proper yet, right? It's a great idea and will make porting easier for everyone.

First I assume that the editor doesn't allow to modify some data that are still managed internally, like ContentItemId, ContentItemVersionId, Latest, Published and so on.

You can edit the ContentItemId, this could be used to clone the current item by publishing it with a different ID. ContentItemVersionId, Latest, and Published are overwritten to values you'd expect from a Publish type action.

And I saw that from the editor we only can publish, not save as a draft. Why not if it is an advanced feature intended to act directly on a given version record

You can always turn the content item into a draft using the stock actions menu after it's saved. I almost never use drafts so I had no interest adding this feature at the moment. (my interest is only relevant here because I made this contrib on my own initiative, outside of work) If there is any demand for saving a draft, the editor can be expanded later without breaking changes.

if an item is already Published, PublishAsync() does nothing, that why I think you needed to call _session.Save().

In the current version I just set the contentItem.Published to false before calling PublishAshync to force it, instead of calling _session.Save(). But that's delving int implementation detail, I think it would be better if there was an additional optional parameter on the method like IContentManager.PublishAsync(ContentItem contentItem, bool forcePublish = false). What do you think? If no problem with it comes to mind, I may submit a PR for that in OC.

So what I suggest is to follow the pattern used in the ApiController.Post of the OC.Contents module.

So would it make more sense to ditch the highlighted code and just pass the content item to ApiController.Post directly? That would be a lot of code to copy/reimplement...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried just passing the content item to ApiController.Post and it works great! Now that it's trivial to handle drafts by just passing true to the second parameter, I've added that too.
The only tricky part was that this action requires Permissions.AccessContentApi permission, but it's not so hard to grant that to a temporary claims principal, so I did that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I understand the context so feel free to do whatever you think is better, these are only suggestions.

  • We already have a dedicated converter allowing to serialize / deserialize a content item, you are using it here and it will be the same with STJ. Hmm maybe in the controller action you could directly bind to a content item as done in the Api controller action.

  • Okay I understand, it is more like an advanced feature allowing to update a given version record without having to deal with validations, draft versions and so on. I was not sure but yes in that case my suggestions are less relevant and maybe what you first did was better.

  • Good idea to use the Api action but I would not recommend this pattern, I think it is better to keep a dedicated controller action that would be easier to tweak.

  • Otherwise, following my first bad idea, once you have a deserialized content item, could be as simple as the following.

    ...
    var contentItem = await _contentManager.GetAsync(
        deserialized.ContentItemId,
        VersionOptions.DraftRequired);
    if (contentItem is null)
    {
        return NotFound();
    }
    
    if (!await _authorizationService.AuthorizeAsync(
        User,
        CommonPermissions.EditContent,
        contentItem))
    {
        return Forbid();
    }
    
    contentItem.Merge(deserialized, _updateJsonMergeSettings);
    await _contentManager.UpdateAsync(contentItem);
    
    var result = await _contentManager.ValidateAsync(contentItem);
    if (!result.Succeeded)
    {
        // Whatever is better to do.
    }
    
    await _contentManager.PublishAsync(contentItem);
    ...
    

Piedone marked this conversation as resolved.
Show resolved Hide resolved

if (!string.IsNullOrEmpty(returnUrl) &&
submitPublish != "submit.PublishAndContinue" &&
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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using OrchardCore.ContentManagement;
using OrchardCore.ContentManagement.Display.ContentDisplay;
using OrchardCore.ContentManagement.Display.ViewModels;
using OrchardCore.DisplayManagement.ModelBinding;
using OrchardCore.DisplayManagement.Views;

namespace Lombiq.JsonEditor.Drivers;

public class EditJsonActionsMenuContentDisplayDriver : ContentDisplayDriver
{
public override IDisplayResult Display(ContentItem model, IUpdateModel updater) =>
Initialize<ContentItemViewModel>("Content_EditJsonActions", a => a.ContentItem = model.ContentItem)
sarahelsaig marked this conversation as resolved.
Show resolved Hide resolved
.Location("ActionsMenu:after");
}
20 changes: 20 additions & 0 deletions Lombiq.JsonEditor/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,45 @@
using Lombiq.JsonEditor.Constants;
using Lombiq.JsonEditor.Controllers;
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.ContentTypes.Editors;
using OrchardCore.Modules;
using OrchardCore.Mvc.Core.Utilities;
using OrchardCore.ResourceManagement;
using System;

namespace Lombiq.JsonEditor;

public class Startup : StartupBase
{
private readonly AdminOptions _adminOptions;

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

public override void ConfigureServices(IServiceCollection services)
{
services.AddTransient<IConfigureOptions<ResourceManagementOptions>, ResourceManagementOptionsConfiguration>();
services.AddTagHelpers<JsonEditorTagHelper>();

services.AddContentField<JsonField>().UseDisplayDriver<JsonFieldDisplayDriver>();
services.AddScoped<IContentPartFieldDefinitionDisplayDriver, JsonFieldSettingsDriver>();

services.AddScoped<IContentDisplayDriver, EditJsonActionsMenuContentDisplayDriver>();
}

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) });
}
14 changes: 14 additions & 0 deletions Lombiq.JsonEditor/ViewModels/EditContentItemViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using OrchardCore.ContentManagement;

namespace Lombiq.JsonEditor.ViewModels;

public record EditContentItemViewModel(
Piedone marked this conversation as resolved.
Show resolved Hide resolved
string ContentItemId,
string DisplayText,
string Json)
{
public EditContentItemViewModel(ContentItem contentItem, string json)
: this(contentItem.ContentItemId, contentItem.DisplayText, json)
{
}
}
24 changes: 24 additions & 0 deletions Lombiq.JsonEditor/Views/Admin/Edit.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@model Lombiq.JsonEditor.ViewModels.EditContentItemViewModel
@{
var returnUrl = Context.Request.Query["returnUrl"];
}

<form method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="contentItemId" value="@Model.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 (!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>