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

OSOE-638: Feature to edit shell settings from the admin in Lombiq.Hosting.Tenants #84

Merged
merged 64 commits into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
7da1e96
Adding Shell Settings Editor display
wAsnk Sep 21, 2023
ce050bd
Trying to override shellsettings for tenant
wAsnk Sep 21, 2023
4803437
Return to editor
wAsnk Sep 21, 2023
8777f60
Cleaning up
wAsnk Sep 21, 2023
eb16def
Showing only related data
wAsnk Sep 26, 2023
4228b8f
Merge remote-tracking branch 'origin/dev' into issue/OSOE-638
wAsnk Sep 26, 2023
bdbee45
Merge remote-tracking branch 'origin/dev' into issue/OSOE-638
wAsnk Sep 26, 2023
ef4f375
Testing saving settings
wAsnk Sep 26, 2023
74a92bc
Merge remote-tracking branch 'origin/dev' into issue/OSOE-638
wAsnk Oct 2, 2023
0c809a8
Saving not already existing values.
wAsnk Oct 2, 2023
d7da847
Not needed anymore
wAsnk Oct 3, 2023
5ad6859
Cleanup and logic change
wAsnk Oct 3, 2023
2186685
Removing not needed usings
wAsnk Oct 3, 2023
7ac2d66
Merge remote-tracking branch 'origin/dev' into issue/OSOE-638
wAsnk Oct 3, 2023
971f1c2
Ignore spell check
wAsnk Oct 3, 2023
f97fa20
Removing lines
wAsnk Oct 3, 2023
4048834
Adding docs
wAsnk Oct 3, 2023
8ad8cd8
Fixing UI test
wAsnk Oct 4, 2023
a52bb2a
Using only what's necessary.
wAsnk Oct 4, 2023
d6ea51e
Update Lombiq.Hosting.Tenants.Management/Manifest.cs
wAsnk Oct 4, 2023
7535d01
Update Lombiq.Hosting.Tenants.Management/Readme.md
wAsnk Oct 4, 2023
47d797b
Update Lombiq.Hosting.Tenants.Management/Readme.md
wAsnk Oct 4, 2023
4b89d44
Make it possible to remove values
wAsnk Oct 4, 2023
2c056d3
Renaming
wAsnk Oct 4, 2023
9d6a1bb
Not needed
wAsnk Oct 4, 2023
ff90081
Code cleanup
wAsnk Oct 4, 2023
e782cd7
Not needed
wAsnk Oct 4, 2023
19c8660
Merge remote-tracking branch 'origin/issue/OSOE-638' into issue/OSOE-638
wAsnk Oct 4, 2023
024c4fc
Using distributed lock instead
wAsnk Oct 5, 2023
d004ff8
Cleanup
wAsnk Oct 5, 2023
5cfefa9
Adding hint
wAsnk Oct 5, 2023
dbd4bcb
Adding UI test
wAsnk Oct 5, 2023
c03aa91
Adding to solution
wAsnk Oct 5, 2023
8e1f939
Using alpha
wAsnk Oct 5, 2023
17f1d9a
Updating Tests UI version
wAsnk Oct 5, 2023
453b3e7
Setting nuget version back
wAsnk Oct 5, 2023
d96a5aa
Testing settings delete
wAsnk Oct 9, 2023
4196c68
Better key removal logic
wAsnk Oct 9, 2023
3d2bafa
Removing navigation to dashboard
wAsnk Oct 9, 2023
62aba3c
Suppressing warning
wAsnk Oct 9, 2023
b15fe7b
Modifying text
wAsnk Oct 9, 2023
427324a
Using already defined part
wAsnk Oct 9, 2023
0c905fb
Using Json nodes instead of key value pairs
wAsnk Oct 9, 2023
b0aff93
Adding docs
wAsnk Oct 9, 2023
ad27f5b
Updating UI test to match new logic
wAsnk Oct 9, 2023
cc5bd27
Update Lombiq.Hosting.Tenants.Management.Tests.UI/Extensions/TestCase…
wAsnk Oct 9, 2023
0f1ddcf
Update Lombiq.Hosting.Tenants.Management/Controllers/ShellSettingsEdi…
wAsnk Oct 9, 2023
c2820ea
Fresh copy and modifying again to accept simple string
wAsnk Oct 9, 2023
5c54b1d
Removing null check
wAsnk Oct 9, 2023
a01cd73
Using new constant
wAsnk Oct 9, 2023
6082053
Renaming field
wAsnk Oct 9, 2023
ba49da1
Using vars
wAsnk Oct 9, 2023
32de4ce
Renaming
wAsnk Oct 9, 2023
a8c1d5e
Adding notifier
wAsnk Oct 9, 2023
12922dc
Adding comment
wAsnk Oct 9, 2023
8c243f9
Using UpdateShellSettingsAsync to update version id and reload shell
wAsnk Oct 9, 2023
ea3a259
Adding test
wAsnk Oct 9, 2023
6394f2b
Adding comment
wAsnk Oct 11, 2023
e183f5a
Merge remote-tracking branch 'origin/dev' into issue/OSOE-638
wAsnk Oct 11, 2023
e130b24
Filling json from validation error
wAsnk Oct 12, 2023
b98f7fb
Adding test cases
wAsnk Oct 12, 2023
4edc564
Update Lombiq.Hosting.Tenants.Management/Service/JsonConfigurationPar…
wAsnk Oct 12, 2023
2800297
Update Lombiq.Hosting.Tenants.Management/Controllers/ShellSettingsEdi…
wAsnk Oct 12, 2023
83f9e95
Update UI Kit version
wAsnk Oct 13, 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 @@ -11,11 +11,34 @@ public static class TestCaseUITestContextExtensions
{
public static async Task TestShellSettingsEditorFeatureAsync(this UITestContext context)
{
await context.SignInDirectlyAndGoToDashboardAsync();
await context.SignInDirectlyAsync();
await context.GoToAdminRelativeUrlAsync("/Tenants/Edit/Default");
context.FillInMonacoEditor("Json_editor", "{\"TestKey:TestSubKey:TestSubOptions:Test\":\"TestValue\"}");

// Expected JSON string
wAsnk marked this conversation as resolved.
Show resolved Hide resolved
#pragma warning disable JSON002 // Probable JSON string detected
await context.FillInEditorThenCheckValueAsync(
"{\"TestKey\":{\"TestSubKey\":{\"TestSubOptions\":{\"Test\": \"TestValue\"}}}}",
"TestValue");
#pragma warning restore JSON002 // Probable JSON string detected
await context.FillInEditorThenCheckValueAsync(
string.Empty,
expectedValue: null);
}

private static async Task FillInEditorThenCheckValueAsync(this UITestContext context, string text, string expectedValue)
{
context.FillInMonacoEditor("Json_editor", text);
await context.ClickReliablyOnAsync(By.XPath("//button[contains(.,'Save settings')]"));
var editorValue = JObject.Parse(context.GetMonacoEditorText("Json_editor"));
editorValue.Value<string>("TestKey:TestSubKey:TestSubOptions:Test").ShouldBeAsString("TestValue");
var editorText = context.GetMonacoEditorText("Json_editor");

if (string.IsNullOrEmpty(text))
{
editorText.ShouldBeAsString(text);
}
else
{
var editorValue = JObject.Parse(context.GetMonacoEditorText("Json_editor"));
editorValue.SelectToken("TestKey.TestSubKey.TestSubOptions.Test")?.ToString().ShouldBeAsString(expectedValue);
}
}
}
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,8 +1,9 @@
using Lombiq.Hosting.Tenants.Management.Constants;
using Lombiq.Hosting.Tenants.Management.Models;
using Lombiq.Hosting.Tenants.Management.Service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Microsoft.Extensions.Configuration;
using OrchardCore.Environment.Shell;
using OrchardCore.Environment.Shell.Configuration;
using OrchardCore.Locking.Distributed;
Expand All @@ -12,6 +13,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using static OrchardCore.Tenants.Permissions;

Expand Down Expand Up @@ -47,29 +49,56 @@ public async Task<IActionResult> Edit(ShellSettingsEditorViewModel model)
return NotFound();
}

var settingsDictionary = JsonConvert.DeserializeObject<Dictionary<string, string>>(model.Json);
var newSettings = new Dictionary<string, string>();

foreach (var key in settingsDictionary.Keys.Where(key => string.IsNullOrEmpty(settingsDictionary[key])))
model.Json ??= "{}";
if (!IsValidJson(model.Json))
{
settingsDictionary[key] = null;
return RedirectToAction(
nameof(AdminController.Edit),
typeof(AdminController).ControllerName(),
new
{
area = "OrchardCore.Tenants",
id = model.TenantId,
});
}
Piedone marked this conversation as resolved.
Show resolved Hide resolved

foreach (var key in settingsDictionary.Keys)
var settingsDictionary = new JsonConfigurationParser().ParseConfiguration(model.Json);
Piedone marked this conversation as resolved.
Show resolved Hide resolved
var newSettings = new Dictionary<string, string>();

var tenantSettingsPrefix = $"{model.TenantId}Prefix:";
var currentSettings = shellSettings.ShellConfiguration.AsEnumerable()
.Where(item => item.Value != null &&
item.Key.Contains(tenantSettingsPrefix))
.ToDictionary(key => key.Key.Replace(tenantSettingsPrefix, string.Empty), value => value.Value);

if (settingsDictionary?.Keys != null)
{
if (shellSettings[key] != settingsDictionary[key])
foreach (var key in settingsDictionary.Keys)
{
var tenantSettingsPrefix = $"{model.TenantId}Prefix:{key}";
newSettings[tenantSettingsPrefix] = settingsDictionary[key];
newSettings[key] = settingsDictionary[key];
var tenantSettingsPrefixWithKey = $"{tenantSettingsPrefix}{key}";
if (shellSettings[key] != settingsDictionary[key])
{
newSettings[tenantSettingsPrefixWithKey] = settingsDictionary[key];
newSettings[key] = settingsDictionary[key];
}
}
}

// Try to acquire a lock before using the scope, so that a next process gets the last committed data.
var (locker, locked) = await _distributedLock.TryAcquireLockAsync("SHELL_SETTINGS_EDITOR_LOCK", TimeSpan.MaxValue);
var deletableKeys = currentSettings
.Where(item => settingsDictionary == null || !settingsDictionary.ContainsKey(item.Key))
.Select(item => item.Key);

foreach (var key in deletableKeys)
{
var tenantSettingsPrefixWithKey = $"{tenantSettingsPrefix}{key}";
newSettings[key] = null;
newSettings[tenantSettingsPrefixWithKey] = null;
}

var (locker, locked) = await _distributedLock.TryAcquireLockAsync("SHELL_SETTINGS_EDITOR_LOCK", TimeSpan.FromSeconds(10));
wAsnk marked this conversation as resolved.
Show resolved Hide resolved
if (!locked)
{
throw new TimeoutException($"Failed to acquire a lock before saving settings to the tenant: {model.TenantId}");
throw new TimeoutException($"Failed to acquire a lock before saving settings to the tenant: {model.TenantId}.");
}

await using var acquiredLock = locker;
Expand All @@ -86,4 +115,17 @@ public async Task<IActionResult> Edit(ShellSettingsEditorViewModel model)
id = model.TenantId,
});
}

private static bool IsValidJson(string json)
{
try
{
JsonDocument.Parse(json);
return true;
}
catch (JsonException)
{
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
using Lombiq.Hosting.Tenants.Management.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using OrchardCore.DisplayManagement;
using OrchardCore.DisplayManagement.Layout;
using OrchardCore.Environment.Shell;
using OrchardCore.Environment.Shell.Configuration;
using OrchardCore.Mvc.Core.Utilities;
using OrchardCore.Tenants.Controllers;
using System.Linq;
using System.Threading.Tasks;

namespace Lombiq.Hosting.Tenants.Management.Filters;
Expand Down Expand Up @@ -49,18 +47,15 @@ actionRouteValue is nameof(AdminController.Edit) &&

var layout = await _layoutAccessor.GetLayoutAsync();
var contentZone = layout.Zones["Content"];
var tenantSettingsPrefix = $"{tenantName}Prefix:";
var editableItems = shellSettings.ShellConfiguration.AsEnumerable()
.Where(item => item.Value != null &&
item.Key.Contains(tenantSettingsPrefix))
.ToDictionary(key => key.Key.Replace(tenantSettingsPrefix, string.Empty), value => value.Value);
var tenantSettingsPrefix = $"{tenantName}Prefix";
var editableItems = shellSettings.ShellConfiguration.AsJsonNode();

await contentZone.AddAsync(
await _shapeFactory.CreateAsync<ShellSettingsEditorViewModel>(
"ShellSettingsEditor",
viewModel =>
{
viewModel.Json = JsonConvert.SerializeObject(editableItems);
viewModel.Json = editableItems[tenantSettingsPrefix]?.ToJsonString();
viewModel.TenantId = tenantName;
}),
"10");
Expand Down
2 changes: 1 addition & 1 deletion Lombiq.Hosting.Tenants.Management/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## About

With the help of this module, you can set restrictions on tenant creation.
With the help of this module, you can set restrictions on tenant creation and set tenant level shell settings in the tenant editor.

## Documentation

Expand Down
109 changes: 109 additions & 0 deletions Lombiq.Hosting.Tenants.Management/Service/JsonConfigurationParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;

namespace Lombiq.Hosting.Tenants.Management.Service;

public class JsonConfigurationParser
{
private readonly Dictionary<string, string> Data = new(StringComparer.OrdinalIgnoreCase);
Piedone marked this conversation as resolved.
Show resolved Hide resolved
private readonly Stack<string> Paths = new();
Piedone marked this conversation as resolved.
Show resolved Hide resolved

public IDictionary<string, string> ParseConfiguration(string inputJson)
{
var jsonDocumentOptions = new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};

using var doc = JsonDocument.Parse(inputJson, jsonDocumentOptions);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
{
throw new FormatException("Format Exception:" + doc.RootElement.ValueKind);
Piedone marked this conversation as resolved.
Show resolved Hide resolved
}

VisitObjectElement(doc.RootElement);

return Data;
}

private void VisitObjectElement(JsonElement element)
{
var isEmpty = true;

foreach (var property in element.EnumerateObject())
{
isEmpty = false;
EnterContext(property.Name);
VisitValue(property.Value);
ExitContext();
}

SetNullIfElementIsEmpty(isEmpty);
}

private void VisitArrayElement(JsonElement element)
{
int index = 0;

foreach (var arrayElement in element.EnumerateArray())
{
EnterContext(index.ToString(CultureInfo.InvariantCulture));
VisitValue(arrayElement);
ExitContext();
index++;
}

SetNullIfElementIsEmpty(isEmpty: index == 0);
}

private void SetNullIfElementIsEmpty(bool isEmpty)
{
if (isEmpty && Paths.Count > 0)
{
Data[Paths.Peek()] = null;
}
}

private void VisitValue(JsonElement value)
{
switch (value.ValueKind)
{
case JsonValueKind.Object:
VisitObjectElement(value);
break;

case JsonValueKind.Array:
VisitArrayElement(value);
break;

case JsonValueKind.Number:
case JsonValueKind.String:
case JsonValueKind.True:
case JsonValueKind.False:
case JsonValueKind.Null:
string key = Paths.Peek();
Piedone marked this conversation as resolved.
Show resolved Hide resolved
if (Data.ContainsKey(key))
{
throw new FormatException("Key Is Duplicated:" + key);
}

Data[key] = value.ToString();
break;

case JsonValueKind.Undefined:
default:
throw new FormatException("Unsupported JSON Token:" + value.ValueKind);
}
}

private void EnterContext(string context) =>
Paths.Push(Paths.Count > 0 ?
Paths.Peek() + ConfigurationPath.KeyDelimiter + context :
context);

private void ExitContext() => Paths.Pop();
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
<div class="@Orchard.GetWrapperCssClasses("field-wrapper")" id="FieldWrapper">
<label asp-for="Json" class="@Orchard.GetLabelCssClasses()">@T["Shell Settings editor"]</label>
<div class="@Orchard.GetEndCssClasses()">
<span class="hint">@T["Key-value pairs should be set here. Save empty string value to delete setting."]</span>
<span class="hint">
@T["JSON key-value pairs should be set here, in the same way you'd configure the tenant-specific section in an appsettings.json file."]
</span>
<div id="@Html.IdFor(model => model.Json)_editor" asp-for="Settings" style="min-height: 400px;" class="form-control"></div>
<textarea asp-for="Json" hidden>@Html.Raw(Model.Json)</textarea>
</div>
Expand Down