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

Add HTML support to notification summary #15607

Merged
merged 10 commits into from
Apr 1, 2024
4 changes: 4 additions & 0 deletions src/OrchardCore.Cms.Web/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@
// }
// ]
//},
//"OrchardCore_Notifications": {
// "TotalUnreadNotifications": 10,
// "DisableNotificationHtmlBodySanitizer": false
//},
//"OrchardCore_HealthChecks": {
// "Url": "/health/live"
//},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@model ContentTaskViewModel<UpdateContentTask>

<header>
<h4><i class="fa-solid fa-pencil-square-o" aria-hidden="true"></i>@Model.Activity.GetTitleOrDefault(() => T["Update Content"])</h4>
<h4><i class="fa-solid fa-square-pen" aria-hidden="true"></i>@Model.Activity.GetTitleOrDefault(() => T["Update Content"])</h4>
</header>

@if (string.IsNullOrWhiteSpace(Model.Activity.Content?.Expression))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ public WorkflowExpression<string> Subject
set => SetProperty(value);
}

public WorkflowExpression<string> Summary
{
get => GetProperty(() => new WorkflowExpression<string>());
set => SetProperty(value);
}

public WorkflowExpression<string> TextBody
{
get => GetProperty(() => new WorkflowExpression<string>());
Expand Down Expand Up @@ -97,7 +103,8 @@ protected virtual async Task<INotificationMessage> GetMessageAsync(WorkflowExecu
{
return new NotificationMessage()
{
Summary = await _expressionEvaluator.EvaluateAsync(Subject, workflowContext, null),
Subject = await _expressionEvaluator.EvaluateAsync(Subject, workflowContext, null),
Summary = await _expressionEvaluator.EvaluateAsync(Summary, workflowContext, _htmlEncoder),
TextBody = await _expressionEvaluator.EvaluateAsync(TextBody, workflowContext, null),
HtmlBody = await _expressionEvaluator.EvaluateAsync(HtmlBody, workflowContext, _htmlEncoder),
IsHtmlPreferred = IsHtmlPreferred,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,49 +11,51 @@ notificationManager = function () {
var elements = document.getElementsByClassName('mark-notification-as-read');

for (let i = 0; i < elements.length; i++) {
let element = elements[i];
element.addEventListener('click', () => {

if (element.getAttribute('data-is-read') != "false") {
return;
}

var messageId = element.getAttribute('data-message-id');

if (!messageId) {
return;
}

fetch(readUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ messageId: messageId })
}).then(response => response.json())
.then(result => {
if (result.updated) {
if (wrapperSelector) {
var wrapper = element.closest(wrapperSelector);
if (wrapper) {
wrapper.classList.remove('notification-is-unread');
wrapper.classList.add('notification-is-read');
wrapper.setAttribute('data-is-read', true);

['click', 'mouseover'].forEach((evt) => {
elements[i].addEventListener(evt, (e) => {

if (e.target.getAttribute('data-is-read') != "false") {
return;
}

var messageId = e.target.getAttribute('data-message-id');

if (!messageId) {
return;
}

fetch(readUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ messageId: messageId })
}).then(response => response.json())
.then(result => {
if (result.updated) {
if (wrapperSelector) {
var wrapper = e.target.closest(wrapperSelector);
if (wrapper) {
wrapper.classList.remove('notification-is-unread');
wrapper.classList.add('notification-is-read');
wrapper.setAttribute('data-is-read', true);
}
} else {
e.target.classList.remove('notification-is-unread');
e.target.classList.add('notification-is-read');
e.target.setAttribute('data-is-read', true);
}
} else {
element.classList.remove('notification-is-unread');
element.classList.add('notification-is-read');
element.setAttribute('data-is-read', true);
}
}

var targetUrl = element.getAttribute('data-target-url');
var targetUrl = e.target.getAttribute('data-target-url');

if (targetUrl) {
window.location.href = targetUrl;
}
});
});
if (targetUrl) {
window.location.href = targetUrl;
}
});
});
})
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,31 +112,31 @@ public async Task<IActionResult> List(

var queryResult = await _notificationsAdminListQueryService.QueryAsync(pager.Page, pager.PageSize, options, this);

dynamic pagerShape = await _shapeFactory.PagerAsync(pager, queryResult.TotalCount, options.RouteValues);
var pagerShape = await _shapeFactory.PagerAsync(pager, queryResult.TotalCount, options.RouteValues);

var notificationSummaries = new List<dynamic>();
var notificationShapes = new List<IShape>();

foreach (var notification in queryResult.Notifications)
{
dynamic shape = await _notificationDisplayManager.BuildDisplayAsync(notification, this, "SummaryAdmin");
shape.Notification = notification;
var shape = await _notificationDisplayManager.BuildDisplayAsync(notification, this, "SummaryAdmin");
shape.Properties[nameof(Notification)] = notification;

notificationSummaries.Add(shape);
notificationShapes.Add(shape);
}

var startIndex = (pagerShape.Page - 1) * pagerShape.PageSize + 1;
var startIndex = (pager.Page - 1) * pager.PageSize + 1;
options.StartIndex = startIndex;
options.EndIndex = startIndex + notificationSummaries.Count - 1;
options.NotificationsCount = notificationSummaries.Count;
options.TotalItemCount = pagerShape.TotalItemCount;
options.EndIndex = startIndex + notificationShapes.Count - 1;
options.NotificationsCount = notificationShapes.Count;
options.TotalItemCount = queryResult.TotalCount;

var header = await _notificationOptionsDisplayManager.BuildEditorAsync(options, this, false, string.Empty, string.Empty);

var shapeViewModel = await _shapeFactory.CreateAsync<ListNotificationsViewModel>("NotificationsAdminList", viewModel =>
{
viewModel.Options = options;
viewModel.Header = header;
viewModel.Notifications = notificationSummaries;
viewModel.Notifications = notificationShapes;
viewModel.Pager = pagerShape;
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,33 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using OrchardCore.Admin.Models;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
using OrchardCore.Notifications.Indexes;
using OrchardCore.Notifications.Models;
using OrchardCore.Notifications.ViewModels;
using YesSql;

namespace OrchardCore.Notifications.Drivers;

public class NotificationNavbarDisplayDriver : DisplayDriver<Navbar>
{
// TODO, make this part of a configurable of NotificationOptions
private const int MaxVisibleNotifications = 10;

private readonly IAuthorizationService _authorizationService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly NotificationOptions _notificationOptions;
private readonly YesSql.ISession _session;

public NotificationNavbarDisplayDriver(
IAuthorizationService authorizationService,
IHttpContextAccessor httpContextAccessor,
IOptions<NotificationOptions> notificationOptions,
YesSql.ISession session)
{
_authorizationService = authorizationService;
_httpContextAccessor = httpContextAccessor;
_notificationOptions = notificationOptions.Value;
_session = session;
}

Expand All @@ -37,11 +39,11 @@ public override IDisplayResult Display(Navbar model)
var userId = _httpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
var notifications = (await _session.Query<Notification, NotificationIndex>(x => x.UserId == userId && !x.IsRead, collection: NotificationConstants.NotificationCollection)
.OrderByDescending(x => x.CreatedAtUtc)
.Take(MaxVisibleNotifications + 1)
.Take(_notificationOptions.TotalUnreadNotifications + 1)
.ListAsync()).ToList();

model.Notifications = notifications;
model.MaxVisibleNotifications = MaxVisibleNotifications;
model.MaxVisibleNotifications = _notificationOptions.TotalUnreadNotifications;
model.TotalUnread = notifications.Count;

}).Location("Detail", "Content:9")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using OrchardCore.Infrastructure.Html;
using OrchardCore.Liquid;
using OrchardCore.Notifications.Activities;
using OrchardCore.Notifications.Models;

namespace OrchardCore.Notifications.Drivers;

public class NotifyContentOwnerTaskDisplayDriver : NotifyUserTaskActivityDisplayDriver<NotifyContentOwnerTask>
{

public NotifyContentOwnerTaskDisplayDriver(
IHtmlSanitizerService htmlSanitizerService,
ILiquidTemplateManager liquidTemplateManager,
IOptions<NotificationOptions> notificationOptions,
IStringLocalizer<NotifyContentOwnerTaskDisplayDriver> stringLocalizer)
: base(htmlSanitizerService, liquidTemplateManager, notificationOptions, stringLocalizer)
{
}
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,82 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using OrchardCore.DisplayManagement.ModelBinding;
using OrchardCore.DisplayManagement.Views;
using OrchardCore.Infrastructure.Html;
using OrchardCore.Liquid;
using OrchardCore.Mvc.ModelBinding;
using OrchardCore.Notifications.Activities;
using OrchardCore.Notifications.Models;
using OrchardCore.Notifications.ViewModels;
using OrchardCore.Workflows.Display;
using OrchardCore.Workflows.Models;
using OrchardCore.Workflows.ViewModels;

namespace OrchardCore.Notifications.Drivers;

public class NotifyUserTaskActivityDisplayDriver<TActivity, TEditViewModel> : ActivityDisplayDriver<TActivity, TEditViewModel>
public abstract class NotifyUserTaskActivityDisplayDriver<TActivity, TEditViewModel> : ActivityDisplayDriver<TActivity, TEditViewModel>
where TActivity : NotifyUserTaskActivity
where TEditViewModel : NotifyUserTaskActivityViewModel, new()
{
private readonly IHtmlSanitizerService _htmlSanitizerService;
private readonly ILiquidTemplateManager _liquidTemplateManager;
private readonly NotificationOptions _notificationOptions;

protected readonly IStringLocalizer S;

protected virtual string EditShapeType => $"{nameof(NotifyUserTaskActivity)}_Fields_Edit";

public NotifyUserTaskActivityDisplayDriver(
IHtmlSanitizerService htmlSanitizerService,
ILiquidTemplateManager liquidTemplateManager,
IOptions<NotificationOptions> notificationOptions,
IStringLocalizer stringLocalizer)
{
_htmlSanitizerService = htmlSanitizerService;
_liquidTemplateManager = liquidTemplateManager;
_notificationOptions = notificationOptions.Value;
S = stringLocalizer;
}

public override IDisplayResult Edit(TActivity model)
{
return Initialize(EditShapeType, (Func<TEditViewModel, ValueTask>)(viewModel =>
return Initialize<TEditViewModel>(EditShapeType, viewModel =>
{
return EditActivityAsync(model, viewModel);
})).Location("Content");
}).Location("Content");
}

public async override Task<IDisplayResult> UpdateAsync(TActivity model, IUpdateModel updater)
{
var viewModel = new TEditViewModel();
if (await updater.TryUpdateModelAsync(viewModel, Prefix))
{
await UpdateActivityAsync(viewModel, model);
if (!_liquidTemplateManager.Validate(viewModel.Subject, out var subjectErrors))
{
updater.ModelState.AddModelError(Prefix, nameof(viewModel.Subject), S["Subject field does not contain a valid Liquid expression. Details: {0}", string.Join(' ', subjectErrors)]);
}

if (!_liquidTemplateManager.Validate(viewModel.Summary, out var summaryErrors))
{
updater.ModelState.AddModelError(Prefix, nameof(viewModel.Summary), S["Summary field does not contain a valid Liquid expression. Details: {0}", string.Join(' ', summaryErrors)]);
}

if (!_liquidTemplateManager.Validate(viewModel.TextBody, out var textBodyErrors))
{
updater.ModelState.AddModelError(Prefix, nameof(viewModel.TextBody), S["Text Body field does not contain a valid Liquid expression. Details: {0}", string.Join(' ', textBodyErrors)]);
}

if (!_liquidTemplateManager.Validate(viewModel.HtmlBody, out var htmlBodyErrors))
{
updater.ModelState.AddModelError(Prefix, nameof(viewModel.HtmlBody), S["HTML Body field does not contain a valid Liquid expression. Details: {0}", string.Join(' ', htmlBodyErrors)]);
}

if (updater.ModelState.IsValid)
Copy link
Contributor

@Skrypt Skrypt Apr 4, 2024

Choose a reason for hiding this comment

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

We should not do this at all in the UpdateAsync. Just update the model and send it back to the EditAsync with an UpdateEditorContext. You need to have the values updated to display on that form on validation error. Else, these user values are lost unless you use an asp-for on the input. Also, as long as the ModelState is not valid it should simply not persist the data in DB.

Copy link
Member Author

Choose a reason for hiding this comment

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

@Skrypt This is done in an abstract class which call a virtual method UpdateActivityAsync only if the model is valid. We do similar logic in other base drivers like ActivityDisplayDriver

Why should we not do this?

Copy link
Contributor

Choose a reason for hiding this comment

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

Going to sleep will try to explain tomorrow. See my display driver pr.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think I will just wait for next triage meeting and explain this. Or I should do a video that explains it all.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sounds good. I suggest opening an issue and explain it with video. Maybe a good topic for Tuesday as well. If this should not be done here, then we should change it in other places too since it is a behavior that we use in other places.

Copy link
Contributor

Choose a reason for hiding this comment

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

I was hoping to follow up on this today on meeting but it will be more likely for next time..

Copy link
Member Author

Choose a reason for hiding this comment

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

No meeting today

Copy link
Contributor

Choose a reason for hiding this comment

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

I saw on Gitter 👍🏼

{
await UpdateActivityAsync(viewModel, model);
}
}

return Edit(model);
Expand All @@ -51,6 +98,7 @@ protected override ValueTask EditActivityAsync(TActivity activity, TEditViewMode
protected override void EditActivity(TActivity activity, TEditViewModel model)
{
model.Subject = activity.Subject.Expression;
model.Summary = activity.Summary.Expression;
model.TextBody = activity.TextBody.Expression;
model.HtmlBody = activity.HtmlBody.Expression;
model.IsHtmlPreferred = activity.IsHtmlPreferred;
Expand All @@ -72,8 +120,9 @@ protected override Task UpdateActivityAsync(TEditViewModel model, TActivity acti
protected override void UpdateActivity(TEditViewModel model, TActivity activity)
{
activity.Subject = new WorkflowExpression<string>(model.Subject);
activity.Summary = new WorkflowExpression<string>(_htmlSanitizerService.Sanitize(model.Summary));
activity.TextBody = new WorkflowExpression<string>(model.TextBody);
activity.HtmlBody = new WorkflowExpression<string>(model.HtmlBody);
activity.HtmlBody = new WorkflowExpression<string>(_notificationOptions.DisableNotificationHtmlBodySanitizer ? model.HtmlBody : _htmlSanitizerService.Sanitize(model.HtmlBody));
activity.IsHtmlPreferred = model.IsHtmlPreferred;
}

Expand All @@ -88,7 +137,15 @@ public override IDisplayResult Display(TActivity activity)
}
}

public class NotifyUserTaskActivityDisplayDriver<TActivity> : NotifyUserTaskActivityDisplayDriver<TActivity, NotifyUserTaskActivityViewModel>
where TActivity : NotifyUserTaskActivity
public abstract class NotifyUserTaskActivityDisplayDriver<TActivity> : NotifyUserTaskActivityDisplayDriver<TActivity, NotifyUserTaskActivityViewModel>
where TActivity : NotifyUserTaskActivity
{
public NotifyUserTaskActivityDisplayDriver(
IHtmlSanitizerService htmlSanitizerService,
ILiquidTemplateManager liquidTemplateManager,
IOptions<NotificationOptions> notificationOptions,
IStringLocalizer stringLocalizer)
: base(htmlSanitizerService, liquidTemplateManager, notificationOptions, stringLocalizer)
{
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using OrchardCore.Infrastructure.Html;
using OrchardCore.Liquid;
using OrchardCore.Notifications.Activities;
using OrchardCore.Notifications.Models;

namespace OrchardCore.Notifications.Drivers;

public class NotifyUserTaskDisplayDriver : NotifyUserTaskActivityDisplayDriver<NotifyUserTask>
{
public NotifyUserTaskDisplayDriver(
IHtmlSanitizerService htmlSanitizerService,
ILiquidTemplateManager liquidTemplateManager,
IOptions<NotificationOptions> notificationOptions,
IStringLocalizer<NotifyUserTaskDisplayDriver> stringLocalizer)
: base(htmlSanitizerService, liquidTemplateManager, notificationOptions, stringLocalizer)
{
}
}
Loading
Loading