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 20 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 @@ -26,6 +26,7 @@ public static class TestCaseUITestContextExtensions
public static async Task TestJsonEditorBehaviorAsync(this UITestContext context)
{
await context.EnableJsonEditorFeatureAsync();
Piedone marked this conversation as resolved.
Show resolved Hide resolved
await context.EnableJsonContentEditorFeatureAsync();

await context.ExecuteJsonEditorSampleRecipeDirectlyAsync();

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)}";
}
102 changes: 79 additions & 23 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
@@ -1,42 +1,60 @@
using Lombiq.HelpfulLibraries.OrchardCore.Contents;
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;
using YesSql;

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 ISession _session;
private readonly INotifier _notifier;
private readonly IPageTitleBuilder _pageTitleBuilder;
private readonly IShapeFactory _shapeFactory;
private readonly IStringLocalizer<ApiController> _apiStringLocalizer;
private readonly IStringLocalizer<AdminController> T;
private readonly IHtmlLocalizer<AdminController> H;

public AdminController(
IAuthorizationService authorizationService,
IContentManager contentManager,
IContentDefinitionManager contentDefinitionManager,
ILayoutAccessor layoutAccessor,
ISession session,
INotifier notifier,
IPageTitleBuilder pageTitleBuilder,
IShapeFactory shapeFactory,
IStringLocalizer<AdminController> stringLocalizer)
IOrchardServices<AdminController> services,
IStringLocalizer<ApiController> apiStringLocalizer)
{
_authorizationService = authorizationService;
_contentManager = contentManager;
_authorizationService = services.AuthorizationService.Value;
_contentManager = services.ContentManager.Value;
_contentDefinitionManager = contentDefinitionManager;
_layoutAccessor = layoutAccessor;
_session = session;
_notifier = notifier;
_pageTitleBuilder = pageTitleBuilder;
_shapeFactory = shapeFactory;
T = stringLocalizer;
_apiStringLocalizer = apiStringLocalizer;
T = services.StringLocalizer.Value;
H = services.HtmlLocalizer.Value;
}

public async Task<IActionResult> Edit(string contentItemId)
Expand All @@ -48,14 +66,17 @@ public async Task<IActionResult> Edit(string contentItemId)
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 = T["Edit {0} as JSON", contentItem.ContentType];
model.Title = title;
model.ContentItem = contentItem;
});
await _layoutAccessor.AddShapeToZoneAsync("Title", titleShape);

return View(new EditContentItemViewModel(contentItem, JsonConvert.SerializeObject(contentItem)));
var definition = _contentDefinitionManager.GetTypeDefinition(contentItem.ContentType);
return View(new EditContentItemViewModel(contentItem, definition, JsonConvert.SerializeObject(contentItem)));
}

[ValidateAntiForgeryToken]
Expand All @@ -64,7 +85,8 @@ public async Task<IActionResult> Edit(string contentItemId)
string contentItemId,
string json,
string returnUrl,
[Bind(Prefix = "submit.Publish")] string submitPublish)
[Bind(Prefix = "submit.Publish")] string submitPublish,
[Bind(Prefix = "submit.Save")] string submitSave)
{
if (string.IsNullOrWhiteSpace(contentItemId) ||
string.IsNullOrWhiteSpace(json) ||
Expand All @@ -74,24 +96,29 @@ public async Task<IActionResult> Edit(string contentItemId)
}

if (string.IsNullOrWhiteSpace(contentItem.ContentItemId)) contentItem.ContentItemId = contentItemId;
contentItem = await _contentManager.LoadAsync(contentItem);

if (!await CanEditAsync(contentItem))
{
return NotFound();
}

if (await _contentManager.GetAsync(contentItem.ContentItemId, VersionOptions.Latest) is { } existing)
switch (await UpdateContentAsync(contentItem, submitSave != null))
{
existing.Latest = false;
existing.Published = false;
_session.Save(existing);
contentItem.ContentItemVersionId = 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);
}

await _contentManager.PublishAsync(contentItem);
_session.Save(contentItem);

if (!string.IsNullOrEmpty(returnUrl) &&
submitPublish != "submit.PublishAndContinue" &&
!(IsContinue(submitSave) || IsContinue(submitPublish)) &&
Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
Expand All @@ -102,4 +129,33 @@ public async Task<IActionResult> Edit(string contentItemId)

private Task<bool> CanEditAsync(ContentItem contentItem) =>
_authorizationService.AuthorizeAsync(User, CommonPermissions.EditContent, contentItem);

private async Task<IActionResult> UpdateContentAsync(ContentItem contentItem, bool isDraft)
{
var currentUser = User;
HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(User.Claims.Concat(Permissions.AccessContentApi)));

try
{
using var contentApiController = new ApiController(
Piedone marked this conversation as resolved.
Show resolved Hide resolved
_contentManager,
_contentDefinitionManager,
_authorizationService,
_apiStringLocalizer);
contentApiController.ControllerContext.HttpContext = HttpContext;
return await contentApiController.Post(contentItem, isDraft);
Piedone marked this conversation as resolved.
Show resolved Hide resolved
}
finally
{
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
@@ -1,14 +1,30 @@
using OrchardCore.ContentManagement;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using OrchardCore.ContentManagement;
using OrchardCore.ContentManagement.Display.ContentDisplay;
using OrchardCore.ContentManagement.Display.ViewModels;
using OrchardCore.DisplayManagement.ModelBinding;
using OrchardCore.Contents;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
using System.Threading.Tasks;

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)
.Location("ActionsMenu:after");
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
17 changes: 13 additions & 4 deletions Lombiq.JsonEditor/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Lombiq.HelpfulLibraries.OrchardCore.DependencyInjection;
using Lombiq.JsonEditor.Constants;
using Lombiq.JsonEditor.Controllers;
using Lombiq.JsonEditor.Drivers;
Expand All @@ -21,19 +22,27 @@ 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>();
}
}

[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();
}

public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) =>
Expand Down
13 changes: 4 additions & 9 deletions Lombiq.JsonEditor/ViewModels/EditContentItemViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,14 +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
string ContentItemId,
string DisplayText,
string Json)
{
public EditContentItemViewModel(ContentItem contentItem, string json)
: this(contentItem.ContentItemId, contentItem.DisplayText, json)
{
}
}
ContentItem ContentItem,
ContentTypeDefinition ContentTypeDefinition,
string Json);
18 changes: 16 additions & 2 deletions Lombiq.JsonEditor/Views/Admin/Edit.cshtml
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
@model Lombiq.JsonEditor.ViewModels.EditContentItemViewModel
@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 a properties may be altered or regenerated at that step."]);
sarahelsaig marked this conversation as resolved.
Show resolved Hide resolved
}

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

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

<div class="form-group mb-3">
Expand All @@ -17,6 +26,11 @@

<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>
Expand Down
14 changes: 14 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
sarahelsaig marked this conversation as resolved.
Show resolved Hide resolved

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
Expand Down Expand Up @@ -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
sarahelsaig marked this conversation as resolved.
Show resolved Hide resolved

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:
Expand Down