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

SPAL-15: MVC display condition #131

Merged
merged 9 commits into from
Jun 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ node_modules/
*.user
.pnpm-debug.log
.editorconfig
*.orig
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Lombiq.HelpfulExtensions.Extensions.Widgets.ViewModels;
using Lombiq.HelpfulLibraries.OrchardCore.Contents;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
using OrchardCore.Rules;
using System.Collections.Generic;

namespace Lombiq.HelpfulExtensions.Extensions.Widgets.Drivers;

public abstract class ConditionDisplayDriver<TCondition> : DisplayDriver<Condition, TCondition>
where TCondition : Condition
{
public override IDisplayResult Display(TCondition model) =>
Combine(
InitializeDisplayType(CommonContentDisplayTypes.Summary, model),
InitializeDisplayType(CommonContentDisplayTypes.Thumbnail, model));

public override IDisplayResult Edit(TCondition model) =>
Combine(
InitializeDisplayType(CommonContentDisplayTypes.Detail, model, "Title"),
GetEditor(model));

protected abstract ConditionViewModel GetConditionViewModel(TCondition condition);
protected abstract IDisplayResult GetEditor(TCondition model);

private ShapeResult InitializeDisplayType(string displayType, TCondition model, string shapeTypeSuffix = null) =>
Initialize<ConditionViewModel>(
string.Join('_', new[] { "Condition", "Fields", displayType, shapeTypeSuffix }.WhereNot(string.IsNullOrEmpty)),
target => GetConditionViewModel(model).CopyTo(target))
.Location(displayType, CommonLocationNames.Content);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Lombiq.HelpfulExtensions.Extensions.Widgets.Models;
using Lombiq.HelpfulExtensions.Extensions.Widgets.ViewModels;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;
using OrchardCore.DisplayManagement.ModelBinding;
using OrchardCore.DisplayManagement.Views;
using System.Linq;
using System.Threading.Tasks;

namespace Lombiq.HelpfulExtensions.Extensions.Widgets.Drivers;

public class MvcConditionDisplayDriver : ConditionDisplayDriver<MvcCondition>
{
private readonly IHtmlLocalizer<MvcConditionDisplayDriver> H;
private readonly IStringLocalizer<MvcConditionDisplayDriver> T;
public MvcConditionDisplayDriver(
IHtmlLocalizer<MvcConditionDisplayDriver> htmlLocalizer,
IStringLocalizer<MvcConditionDisplayDriver> stringLocalizer)
{
H = htmlLocalizer;
T = stringLocalizer;
}

protected override IDisplayResult GetEditor(MvcCondition model) =>
Initialize<MvcConditionViewModel>("MvcCondition_Fields_Edit", viewModel =>
{
viewModel.Area = model.Area;
viewModel.Controller = model.Controller;
viewModel.Action = model.Action;

foreach (var (key, value) in model.OtherRouteValues)
{
viewModel.OtherRouteNames.Add(key);
viewModel.OtherRouteValues.Add(value);
}
}).PlaceInContent();

public override async Task<IDisplayResult> UpdateAsync(MvcCondition model, IUpdateModel updater)
{
var viewModel = new MvcConditionViewModel();
if (await updater.TryUpdateModelAsync(viewModel, Prefix))
{
if (viewModel.OtherRouteNames.Count != viewModel.OtherRouteValues.Count)
{
updater.ModelState.AddModelError(
nameof(viewModel.OtherRouteNames),
T["The count of other route value names didn't match the count of other route values."]);
}

model.Area = viewModel.Area;
model.Controller = viewModel.Controller;
model.Action = viewModel.Action;

model.OtherRouteValues.Clear();
for (var i = 0; i < viewModel.OtherRouteNames.Count; i++)
{
model.OtherRouteValues[viewModel.OtherRouteNames[i]] = viewModel.OtherRouteValues[i];
}
}

return Edit(model);
}

protected override ConditionViewModel GetConditionViewModel(MvcCondition condition)
{
IHtmlContentBuilder summaryHint = new HtmlContentBuilder();

static IHtmlContentBuilder AppendIfNotEmpty(IHtmlContentBuilder summaryHint, string value, IHtmlContent label) =>
string.IsNullOrEmpty(value) ? summaryHint : summaryHint.AppendHtml(label).AppendHtml(" ");

summaryHint = AppendIfNotEmpty(summaryHint, condition.Area, H["Area: \"{0}\"", condition.Area]);
summaryHint = AppendIfNotEmpty(summaryHint, condition.Controller, H["Controller: \"{0}\"", condition.Controller]);
summaryHint = AppendIfNotEmpty(summaryHint, condition.Action, H["Action: \"{0}\"", condition.Action]);

if (condition.OtherRouteValues.Any())
{
summaryHint = summaryHint.AppendHtml(
H["Other route values: {0}", JsonConvert.SerializeObject(condition.OtherRouteValues)]);
}

return new ConditionViewModel
{
Title = H["MVC condition"],
Description = H["An MVC condition evaluates the currently route values such as controller and action name."],
SummaryHint = summaryHint,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Lombiq.HelpfulExtensions.Extensions.Widgets.Models;
using Microsoft.AspNetCore.Http;
using OrchardCore.ContentManagement.Display.ContentDisplay;
using OrchardCore.Rules;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace Lombiq.HelpfulExtensions.Extensions.Widgets.Drivers;

public class MvcConditionEvaluatorDriver : ContentDisplayDriver, IConditionEvaluator
{
private readonly IHttpContextAccessor _hca;

public MvcConditionEvaluatorDriver(IHttpContextAccessor hca) =>
_hca = hca;

public ValueTask<bool> EvaluateAsync(Condition condition) => new(Evaluate((MvcCondition)condition));

private bool Evaluate(MvcCondition condition) =>
MatchRouteValue("area", condition.Area) &&
MatchRouteValue("controller", condition.Controller) &&
MatchRouteValue("action", condition.Action) &&
(!condition.OtherRouteValues.Any() || condition.OtherRouteValues.All(pair => MatchRouteValue(pair.Key, pair.Value)));

private bool MatchRouteValue(string name, string value)
{
// Ignore this match operation if the target value is not set.
if (string.IsNullOrWhiteSpace(value)) return true;

return _hca.HttpContext?.Request.RouteValues.TryGetValue(name, out var routeValue) == true &&
value.EqualsOrdinalIgnoreCase(routeValue?.ToString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using Fluid;
using Fluid.Values;
using Lombiq.HelpfulExtensions.Extensions.Widgets.ViewModels;
using Lombiq.HelpfulLibraries.OrchardCore.Liquid;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OrchardCore.Liquid;
using OrchardCore.Navigation;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;

namespace Lombiq.HelpfulExtensions.Extensions.Widgets.Liquid;

public class MenuWidgetLiquidFilter : ILiquidFilter
{
private readonly ILiquidContentDisplayService _liquidContentDisplayService;
private readonly Lazy<IUrlHelper> _urlHelperLazy;
private readonly IStringLocalizer<MenuWidgetLiquidFilter> T;

public MenuWidgetLiquidFilter(
IActionContextAccessor actionContextAccessor,
ILiquidContentDisplayService liquidContentDisplayService,
IStringLocalizer<MenuWidgetLiquidFilter> stringLocalizer,
IUrlHelperFactory urlHelperFactory)
{
_liquidContentDisplayService = liquidContentDisplayService;

_urlHelperLazy = new Lazy<IUrlHelper>(() =>
urlHelperFactory.GetUrlHelper(actionContextAccessor.ActionContext!));

T = stringLocalizer;
}

public ValueTask<FluidValue> ProcessAsync(FluidValue input, FilterArguments arguments, LiquidTemplateContext context)
{
bool noWrapper, localNav;
string classes;
noWrapper = arguments[nameof(noWrapper)].ToBooleanValue();
localNav = arguments[nameof(localNav)].ToBooleanValue();
classes = arguments[nameof(classes)].ToStringValue();

var converter = new LocalizedStringJsonConverter(T);
var serializer = new JsonSerializer();
serializer.Converters.Add(converter);
var serializerSettings = new JsonSerializerSettings();
serializerSettings.Converters.Add(converter);

var menuItems = input?.Type switch
{
FluidValues.String => JsonConvert.DeserializeObject<IList<MenuItem>>(
input!.ToStringValue(),
serializerSettings),
FluidValues.Object => input!.ToObjectValue() switch
{
IEnumerable<MenuItem> enumerable => enumerable.AsList(),
MenuItem single => new[] { single },
JArray jArray => jArray.ToObject<IList<MenuItem>>(serializer),
JObject jObject => new[] { jObject.ToObject<MenuItem>(serializer) },
_ => null,
},
_ => null,
};

UpdateMenuItems(menuItems, localNav);

return _liquidContentDisplayService.DisplayNewAsync<MenuWidgetViewModel>(
WidgetTypes.MenuWidget,
model =>
{
model.NoWrapper = noWrapper;
model.MenuItems = menuItems ?? Array.Empty<MenuItem>();
model.HtmlClasses = classes;
});
}

private void UpdateMenuItems(IEnumerable<MenuItem> menuItems, bool localNav)
{
if (menuItems == null) return;

foreach (var item in menuItems)
{
if (!string.IsNullOrEmpty(item.Url))
{
var finalUrl = _urlHelperLazy.Value.Content(item.Url);
item.Url = finalUrl;
item.Href = finalUrl;
}

item.LocalNav = localNav || item.LocalNav;

UpdateMenuItems(item.Items, localNav);
}
}

public class LocalizedStringJsonConverter : JsonConverter<LocalizedString>
{
private readonly IStringLocalizer T;

public LocalizedStringJsonConverter(IStringLocalizer stringLocalizer) =>
T = stringLocalizer;

public override void WriteJson(JsonWriter writer, LocalizedString value, JsonSerializer serializer) =>
writer.WriteValue(value?.Value);

[SuppressMessage("Style", "IDE0010:Add missing cases", Justification = "We don't want to handle other token types.")]
public override LocalizedString ReadJson(
JsonReader reader,
Type objectType,
LocalizedString existingValue,
bool hasExistingValue,
JsonSerializer serializer)
{
switch (reader.TokenType)
{
case JsonToken.String:
return JToken.ReadFrom(reader).ToObject<string>() is { } text ? T[text] : null;
case JsonToken.StartObject:
var data = new Dictionary<string, string>(
JToken.ReadFrom(reader).ToObject<Dictionary<string, string>>(),
StringComparer.OrdinalIgnoreCase);
return new LocalizedString(data[nameof(LocalizedString.Name)], data[nameof(LocalizedString.Value)]);
default:
throw new InvalidOperationException("Unable to parse JSON!");
}
}
}
}
39 changes: 38 additions & 1 deletion Lombiq.HelpfulExtensions/Extensions/Widgets/Migrations.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Lombiq.HelpfulExtensions.Extensions.Widgets.Models;
using Lombiq.HelpfulLibraries.OrchardCore.Contents;
using OrchardCore.ContentFields.Settings;
using OrchardCore.ContentManagement.Metadata;
using OrchardCore.ContentManagement.Metadata.Settings;
using OrchardCore.Data.Migration;
Expand Down Expand Up @@ -55,7 +57,25 @@ public int Create()
)
);

return 4;
var contentItemWidgetPartName = _contentDefinitionManager.AlterPartDefinition<ContentItemWidget>(builder => builder
.WithField(part => part.ContentToDisplay, field => field
.WithDisplayName("Content to display")
.WithSettings(new ContentPickerFieldSettings
{
DisplayAllContentTypes = true,
Multiple = true,
}))
.WithField(part => part.DisplayType, field => field.WithDisplayName("Display type"))
.WithField(part => part.GroupId, field => field.WithDisplayName("Group ID"))
);

_contentDefinitionManager.AlterTypeDefinition(WidgetTypes.ContentItemWidget, builder => builder
.Securable()
.Stereotype(CommonStereotypes.Widget)
.WithPart(contentItemWidgetPartName)
);

return 5;
}

public int UpdateFrom1()
Expand Down Expand Up @@ -90,4 +110,21 @@ public int UpdateFrom3()

return 4;
}

public int UpdateFrom4()
{
var contentItemWidgetPartName = _contentDefinitionManager.AlterPartDefinition<ContentItemWidget>(builder => builder
.WithField(part => part.ContentToDisplay, field => field.WithDisplayName("Content to display"))
.WithField(part => part.DisplayType, field => field.WithDisplayName("Display type"))
.WithField(part => part.GroupId, field => field.WithDisplayName("Group ID"))
);

_contentDefinitionManager.AlterTypeDefinition(WidgetTypes.ContentItemWidget, builder => builder
.Securable()
.Stereotype(CommonStereotypes.Widget)
.WithPart(contentItemWidgetPartName)
);

return 5;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using OrchardCore.ContentFields.Fields;
using OrchardCore.ContentManagement;

namespace Lombiq.HelpfulExtensions.Extensions.Widgets.Models;

public class ContentItemWidget : ContentPart
{
public ContentPickerField ContentToDisplay { get; set; } = new();
public TextField DisplayType { get; set; } = new();
public TextField GroupId { get; set; } = new();
}
12 changes: 12 additions & 0 deletions Lombiq.HelpfulExtensions/Extensions/Widgets/Models/MvcCondition.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using OrchardCore.Rules;
using System.Collections.Generic;

namespace Lombiq.HelpfulExtensions.Extensions.Widgets.Models;

public class MvcCondition : Condition
{
public string Area { get; set; }
public string Controller { get; set; }
public string Action { get; set; }
public IDictionary<string, string> OtherRouteValues { get; } = new Dictionary<string, string>();
}
Loading