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

SNOW-20: NonEmptyTagHelper, SafeCombinedResult and other improvements. #119

Merged
merged 15 commits into from
Feb 23, 2022
Merged
Show file tree
Hide file tree
Changes from 12 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 Lombiq.HelpfulLibraries/Docs/AspNetCoreLibraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ Please see the inline documentation of each extension methods learn more.

- `ForwardedHeadersApplicationBuilderExtensions`: Provides `UseForwardedHeadersForCloudflareAndAzure()` that forwards proxied headers onto the current request with settings suitable for an app behind Cloudflare and hosted in an Azure App Service.
- `EnvironmentHttpContextExtensions`: Provides shortcuts to determine information about the current hosting environment, like whether the app is running in Development mode.
- `NonEmptyTagHelper`: An attribute tag helper that conditionally hides its element if the provided collection is null or empty. This eliminates a bulky wrapping `@if(collection?.Count > 1) { ... }` expression that would needlessly increase the document's indentation too.
5 changes: 5 additions & 0 deletions Lombiq.HelpfulLibraries/Docs/ContentsLibraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@

- `IContentVersionNumberService`: Service for getting content version number based on how many different versions of it are in the document database. It has a method for getting the version number of the latest version (`GetLatestVersionNumberAsync`) and another one for a specific content version id (`GetCurrentVersionNumberAsync`).


## Models

- `SafeCombinedResult`: A version of `CombinedResult` where the constructor's result list can contain `null`.

Please see the inline documentation of each extension methods learn more.
16 changes: 16 additions & 0 deletions Lombiq.HelpfulLibraries/Docs/FieldsLibraries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Fields Libraries Documentation


Extensions and services working with or related to Orchard Core's `ContentField`s.

## Extensions

- `MediaFieldExtensions`: Adds extension methods to `MediaField` objects.


## Services

- `NoneShapeTableProvider`: A shape table provider that adds a "None" option to every field's display and editor. This renders an empty shape.


Please see the inline documentation of each extension methods learn more.
6 changes: 5 additions & 1 deletion Lombiq.HelpfulLibraries/Docs/UserLibraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ Please see the inline documentation of each method to learn more.

A provider that only has *Administrator* stereotype permissions. Reduces boilerplate.

To use it, override the `AdminPermissions` read-only abstract property with a collection of admin permissions. This base class implements the `GetPermissionsAsync` and `GetDefaultStereotypes` methods with the derived permission collection.
To use it, override the `AdminPermissions` read-only abstract property with a collection of admin permissions. This base class implements the `GetPermissionsAsync` and `GetDefaultStereotypes` methods with the derived permission collection.

## `RoleCommands`

Adds the `addPermissionToRole /RoleName:<rolename> /Permission:<permission>` command so you can easily assign permissions in the recipes.
64 changes: 64 additions & 0 deletions Lombiq.HelpfulLibraries/Libraries/AspNetCore/NonEmptyTagHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
using System.Collections;
using System.Diagnostics.CodeAnalysis;

namespace Lombiq.HelpfulLibraries.Libraries.AspNetCore
{
/// <summary>
/// An attribute tag helper that conditionally hides its element if the provided <see cref="ICollection"/> is <see
/// langword="null"/> or empty.
/// </summary>
/// <example>
/// <para>
/// Instead of this:
/// </para>
/// <code>
/// @if(Model.Items?.Count > 0)
/// {
/// &lt;article&gt;
/// &lt;p&gt;Some other stuff.&lt;/p&gt;
/// &lt;ul&gt;
/// @foreach (var item in Model.Items)
/// {
/// &lt;li&gt;@item&lt;/li&gt;
/// }
/// &lt;/ul&gt;
/// &lt;/article&gt;
/// }
/// </code>
/// <para>
/// You can write this:
/// </para>
/// <code>
/// &lt;article if-not-empty="@Model.Items"&gt;
/// &lt;p&gt;Some other stuff.&lt;/p&gt;
/// &lt;ul&gt;
/// @foreach (var item in Model.Items)
/// {
/// &lt;li&gt;@item&lt;/li&gt;
/// }
/// &lt;/ul&gt;
/// &lt;/article&gt;
/// </code>
/// </example>
/// <remarks>
/// <para>
/// Make sure to include <c>@addTagHelper *, Lombiq.HelpfulLibraries</c> in your _ViewImports.cshtml file.
/// </para>
/// </remarks>
[HtmlTargetElement("*", Attributes = "if-not-empty")]
public class NonEmptyTagHelper : TagHelper
sarahelsaig marked this conversation as resolved.
Show resolved Hide resolved
{
[HtmlAttributeName("if-not-empty")]
[SuppressMessage(
"Usage",
"CA2227:Collection properties should be read only",
Justification = "TagHelper needs the direct access.")]
public ICollection IfNotEmpty { get; set; }

public override void Process(TagHelperContext context, TagHelperOutput output)
{
if (IfNotEmpty?.Count < 1) output.SuppressOutput();
}
}
}
24 changes: 24 additions & 0 deletions Lombiq.HelpfulLibraries/Libraries/Contents/SafeCombinedResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using OrchardCore.DisplayManagement.Views;
using System.Collections.Generic;
using System.Linq;

namespace Lombiq.HelpfulLibraries.Libraries.Contents
{
/// <summary>
/// A version of <see cref="CombinedResult"/> where the constructor's result list can contain <see langword="null"/>
/// that gets filtered out. This way items can easily be made conditional using a ternary operator where one arm is
/// <see langword="null"/>.
/// </summary>
public class SafeCombinedResult : CombinedResult
{
public SafeCombinedResult(params IDisplayResult[] results)
: this(results.AsEnumerable())
{
}

public SafeCombinedResult(IEnumerable<IDisplayResult> results)
: base(results?.Where(item => item != null).ToList() ?? Enumerable.Empty<IDisplayResult>())
{
}
}
}
40 changes: 40 additions & 0 deletions Lombiq.HelpfulLibraries/Libraries/Fields/MediaFieldExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using OrchardCore.Media.Fields;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;

namespace Lombiq.HelpfulLibraries.Libraries.Fields
{
public static class MediaFieldExtensions
{
/// <summary>
/// Returns a collection of tuples from the provided <paramref name="mediaField"/>. Each contains the path from
/// <see cref="MediaField.Paths"/> and the matching alt text from <see cref="MediaField.MediaTexts"/> if there
/// is any. If there is none, the path is used as the text. This is identical behavior to the <see
/// cref="MediaField"/>'s default display.
/// </summary>
public static IEnumerable<(string Path, string Text)> GetPathsAndAltTexts(this MediaField mediaField) =>
GetPathsAndAltTexts(mediaField, format: "{0}");

/// <summary>
/// Returns a collection of tuples from the provided <paramref name="mediaField"/>. Each contains the path from
/// <see cref="MediaField.Paths"/> and the matching alt text from <see cref="MediaField.MediaTexts"/> if there
/// is any. If there is none or if it's empty a new text is generated using <paramref name="format"/>.
/// </summary>
/// <param name="format">
/// When the path doesn't have a matching alt text, this string is used as the format template to generate one.
/// The first parameter is the path, the second is just the file name.
/// </param>
public static IEnumerable<(string Path, string Text)> GetPathsAndAltTexts(
this MediaField mediaField,
string format) =>
Enumerable.Range(0, mediaField.Paths?.Length ?? 0)
.Select(i => (Path: mediaField.Paths[i], Text: mediaField.MediaTexts.ElementAtOrDefault(i)))
.Select((path, text) => (
Path: path,
Text: string.IsNullOrWhiteSpace(text)
? string.Format(CultureInfo.InvariantCulture, format, text, Path.GetFileName(text))
: text));
}
}
80 changes: 80 additions & 0 deletions Lombiq.HelpfulLibraries/Libraries/Fields/NoneShapeTableProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using Microsoft.AspNetCore.Html;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using OrchardCore.ContentManagement.Metadata;
using OrchardCore.DisplayManagement.Descriptors;
using OrchardCore.DisplayManagement.Implementation;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace Lombiq.HelpfulLibraries.Libraries.Fields
{
/// <summary>
/// This provider creates a "None" editor and display type for every field which can be used to hide the field from
/// being editable or displayable respectively.
/// </summary>
public class NoneShapeTableProvider : IShapeTableProvider
{
public const string None = nameof(None);

private readonly IContentDefinitionManager _contentDefinitionManager;

public NoneShapeTableProvider(IContentDefinitionManager contentDefinitionManager) =>
_contentDefinitionManager = contentDefinitionManager;

public void Discover(ShapeTableBuilder builder)
{
var allFieldNames = _contentDefinitionManager
.ListPartDefinitions()
.SelectMany(part => part.Fields)
.Select(field => field.FieldDefinition.Name)
.Distinct();

foreach (var fieldName in allFieldNames)
{
Describe(builder, fieldName, isEditor: true);
Describe(builder, fieldName, isEditor: false);
}
}

private static void Describe(ShapeTableBuilder builder, string fieldName, bool isEditor)
{
// Creates a new editor or display mode entry in the field editor's dropdown menu.
var property = isEditor ? "Editor" : "DisplayMode";
BindShape(
builder,
$"{fieldName}_{(isEditor ? "Option" : "DisplayOption")}__{None}",
context =>
{
var text = context
.ServiceProvider
.GetRequiredService<IStringLocalizer<NoneShapeTableProvider>>()["None"]
.Value;

var selected = context.Value.Properties[property]?.ToString() == None
? "selected"
: string.Empty;

return Task.FromResult<IHtmlContent>(new HtmlString($"<option value=\"{None}\" {selected}>{text}</option>"));
});

// Creates a blank template for the actual edit or display shape.
BindShape(
builder,
$"{fieldName}_{(isEditor ? "Display" : "Edit")}__{None}",
_ => Task.FromResult<IHtmlContent>(new HtmlContentBuilder()));
}

private static void BindShape(
ShapeTableBuilder builder,
string shapeName,
Func<DisplayContext, Task<IHtmlContent>> bindingAsync) =>
builder
.Describe(shapeName)
.Configure(descriptor =>
{
descriptor.Bindings[shapeName] = new ShapeBinding { BindingAsync = bindingAsync, };
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public static class ShapeResultExtensions
/// Uses <see cref="ShapeResult.Location(string)"/> to set the <paramref name="name"/> of the tab and its
/// <paramref name="priority"/> in the order of the tabs.
/// </summary>
public static ShapeResult UseTab(this ShapeResult shapeResult, string name, int priority) =>
shapeResult.Location($"Parts#{name}: {priority.ToTechnicalString()}");
public static ShapeResult UseTab(this ShapeResult shapeResult, string name, int priority, string placement = "Parts") =>
shapeResult.Location($"{placement}#{name}: {priority.ToTechnicalString()}");
}
}
50 changes: 50 additions & 0 deletions Lombiq.HelpfulLibraries/Libraries/Users/RoleCommands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Localization;
using OrchardCore.Environment.Commands;
using OrchardCore.Security;
using System.Threading.Tasks;
using static OrchardCore.Security.Permissions.Permission;

namespace Lombiq.HelpfulLibraries.Libraries.Users
{
public class RoleCommands : DefaultCommandHandler
{
private readonly RoleManager<IRole> _roleManager;

[OrchardSwitch]
public string RoleName { get; set; }

[OrchardSwitch]
public string Permission { get; set; }

public RoleCommands(RoleManager<IRole> roleManager, IStringLocalizer<RoleCommands> localizer)
: base(localizer) =>
_roleManager = roleManager;

[CommandName("addPermissionToRole")]
[CommandHelp("addPermissionToRole " +
"/RoleName:<rolename> " +
"/Permission:<permission> " +
"\r\n\t" + "Adds the permission to the role")]
[OrchardSwitches("RoleName, Permission")]
public async Task AddPermissionToRoleAsync()
{
var role = (Role)await _roleManager.FindByNameAsync(_roleManager.NormalizeKey(RoleName));
role.RoleClaims.Add(new RoleClaim { ClaimType = ClaimType, ClaimValue = Permission });
await _roleManager.UpdateAsync(role);
}

[CommandName("removePermissionFromRole")]
[CommandHelp("removePermissionFromRole " +
"/RoleName:<rolename> " +
"/Permission:<permission> " +
"\r\n\t" + "Removes the permission from the role")]
[OrchardSwitches("RoleName, Permission")]
public async Task RemovePermissionFromRoleAsync()
{
if ((Role)await _roleManager.FindByNameAsync(_roleManager.NormalizeKey(RoleName)) is not { } role) return;
role.RoleClaims.RemoveAll(claim => claim.ClaimType == ClaimType && claim.ClaimValue == Permission);
await _roleManager.UpdateAsync(role);
}
}
}
1 change: 1 addition & 0 deletions Lombiq.HelpfulLibraries/Lombiq.HelpfulLibraries.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<PackageReference Include="Moq.AutoMock" Version="2.3.0" />
<PackageReference Include="OrchardCore.Admin.Abstractions" Version="1.2.2" />
<PackageReference Include="OrchardCore.Alias" Version="1.2.2" />
<PackageReference Include="OrchardCore.Media.Core" Version="1.2.2" />
<PackageReference Include="OrchardCore.Taxonomies" Version="1.2.2" />
<PackageReference Include="OrchardCore.Data.Abstractions" Version="1.2.2" />
<PackageReference Include="OrchardCore.ContentManagement" Version="1.2.2" />
Expand Down