Improved Notifications List View (#6050, #3871)

This commit is contained in:
Chukwuleta Tobechi 2024-07-02 09:55:54 +01:00 committed by GitHub
parent d6d14b4170
commit e0a0406825
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 230 additions and 61 deletions

View file

@ -25,5 +25,6 @@ namespace BTCPayServer.Abstractions.Contracts
public string Body { get; set; }
public string ActionLink { get; set; }
public bool Seen { get; set; }
public string StoreId { get; set; }
}
}

View file

@ -2,7 +2,6 @@ using System;
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
namespace BTCPayServer.Data
{
@ -23,7 +22,6 @@ namespace BTCPayServer.Data
public byte[] Blob { get; set; }
public string Blob2 { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<NotificationData>()

View file

@ -2461,7 +2461,7 @@ namespace BTCPayServer.Tests
var ctrl = acc.GetController<UINotificationsController>();
var newVersion = MockVersionFetcher.MOCK_NEW_VERSION;
var vm = Assert.IsType<Models.NotificationViewModels.IndexViewModel>(
var vm = Assert.IsType<Models.NotificationViewModels.NotificationIndexViewModel>(
Assert.IsType<ViewResult>(await ctrl.Index()).Model);
Assert.True(vm.Skip == 0);

View file

@ -1,17 +1,13 @@
using System;
using System.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.NotificationViewModels;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -23,35 +19,59 @@ namespace BTCPayServer.Controllers
[Route("notifications/{action:lowercase=Index}")]
public class UINotificationsController : Controller
{
private readonly ApplicationDbContextFactory _factory;
private readonly StoreRepository _storeRepo;
private readonly UserManager<ApplicationUser> _userManager;
private readonly NotificationManager _notificationManager;
public UINotificationsController(
StoreRepository storeRepo,
UserManager<ApplicationUser> userManager,
NotificationManager notificationManager)
NotificationManager notificationManager,
ApplicationDbContextFactory factory)
{
_storeRepo = storeRepo;
_userManager = userManager;
_notificationManager = notificationManager;
_factory = factory;
}
[HttpGet]
public async Task<IActionResult> Index(int skip = 0, int count = 50, int timezoneOffset = 0)
public async Task<IActionResult> Index(NotificationIndexViewModel model = null)
{
model ??= new NotificationIndexViewModel { Skip = 0 };
var timezoneOffset = model.TimezoneOffset ?? 0;
model.Status ??= "Unread";
ViewBag.Status = model.Status;
if (!ValidUserClaim(out var userId))
return RedirectToAction("Index", "UIHome");
var res = await _notificationManager.GetNotifications(new NotificationsQuery()
{
Skip = skip,
Take = count,
UserId = userId
});
var stores = await _storeRepo.GetStoresByUserId(userId);
model.Stores = stores.Where(store => !store.Archived).OrderBy(s => s.StoreName).ToList();
var model = new IndexViewModel() { Skip = skip, Count = count, Items = res.Items, Total = res.Count };
await using var dbContext = _factory.CreateContext();
var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}";
var fs = new SearchString(searchTerm, timezoneOffset);
model.Search = fs;
var res = await _notificationManager.GetNotifications(new NotificationsQuery
{
Skip = model.Skip,
Take = model.Count,
UserId = userId,
SearchText = model.SearchText,
Type = fs.GetFilterArray("type"),
Stores = fs.GetFilterArray("store"),
Seen = model.Status == "Unread" ? false : null
});
model.Items = res.Items;
return View(model);
}
[HttpPost]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanManageNotificationsForUser)]
public async Task<IActionResult> FlipRead(string id)

View file

@ -237,7 +237,7 @@ namespace BTCPayServer.HostedServices
if (InvoiceEventNotification.HandlesEvent(b.Name))
{
await _notificationSender.SendNotification(new StoreScope(b.Invoice.StoreId),
new InvoiceEventNotification(b.Invoice.Id, b.Name));
new InvoiceEventNotification(b.Invoice.Id, b.Name, b.Invoice.StoreId));
}
if (b.Name == InvoiceEvent.Created)
{

View file

@ -1,12 +1,20 @@
using System.Collections.Generic;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data;
namespace BTCPayServer.Models.NotificationViewModels
{
public class IndexViewModel : BasePagingViewModel
{
public List<NotificationViewModel> Items { get; set; }
public List<NotificationViewModel> Items { get; set; } = [];
public string SearchText { get; set; }
public string Status { get; set; }
public SearchString Search { get; set; }
public override int CurrentPageCount => Items.Count;
}
public class NotificationIndexViewModel : IndexViewModel
{
public List<StoreData> Stores { get; set; }
}
}

View file

@ -36,6 +36,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
{
vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType;
vm.StoreId = notification.StoreId;
vm.Body =
"A payment that was made to an approved payout by an external wallet is waiting for your confirmation.";
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIStorePullPaymentsController.Payouts),

View file

@ -44,6 +44,7 @@ internal class InviteAcceptedNotification : BaseNotification
{
vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType;
vm.StoreId = notification.StoreId;
vm.Body = $"User {notification.UserEmail} accepted the invite to {notification.StoreName}.";
vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIStoresController.StoreUsers),
"UIStores",

View file

@ -54,6 +54,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
}
vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType;
vm.StoreId = notification?.StoreId;
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIInvoiceController.Invoice),
"UIInvoice",
new { invoiceId = notification.InvoiceId }, _options.RootPath);
@ -64,10 +65,11 @@ namespace BTCPayServer.Services.Notifications.Blobs
{
}
public InvoiceEventNotification(string invoiceId, string invoiceEvent)
public InvoiceEventNotification(string invoiceId, string invoiceEvent, string storeId)
{
InvoiceId = invoiceId;
Event = invoiceEvent;
StoreId = storeId;
}
public static bool HandlesEvent(string invoiceEvent)
@ -77,6 +79,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
public string InvoiceId { get; set; }
public string Event { get; set; }
public string StoreId { get; set; }
public override string Identifier => Event is null ? TYPE : Event.ToStringLowerInvariant();
public override string NotificationType => TYPE;
}

View file

@ -36,6 +36,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
{
vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType;
vm.StoreId = notification.StoreId;
vm.Body = (notification.Status ?? PayoutState.AwaitingApproval) switch
{
PayoutState.AwaitingApproval => "A new payout is awaiting for approval",

View file

@ -1,14 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Notifications
{
@ -28,7 +25,6 @@ namespace BTCPayServer.Services.Notifications
_handlersByNotificationType = handlers.ToDictionary(h => h.NotificationType);
}
public async Task<(List<NotificationViewModel> Items, int? Count)> GetSummaryNotifications(string userId, bool cachedOnly)
{
var cacheKey = GetNotificationsCacheId(userId);
@ -68,7 +64,7 @@ namespace BTCPayServer.Services.Notifications
var queryables = GetNotificationsQueryable(dbContext, query);
var items = (await queryables.withPaging.ToListAsync()).Select(ToViewModel).Where(model => model != null).ToList();
items = FilterNotifications(items, query);
int? count = null;
if (query.Seen is false)
{
@ -134,6 +130,34 @@ namespace BTCPayServer.Services.Notifications
return (queryable, queryable2);
}
private List<NotificationViewModel> FilterNotifications(List<NotificationViewModel> notifications, NotificationsQuery query)
{
if (!string.IsNullOrEmpty(query.SearchText))
{
notifications = notifications.Where(data => data.Body.Contains(query.SearchText)).ToList();
}
if (query.Type?.Length > 0)
{
if (query.Type?.Length > 0)
{
if (query.Type.Contains("userupdate"))
{
notifications = notifications.Where(n => n.Type.Equals("inviteaccepted", StringComparison.OrdinalIgnoreCase) ||
n.Type.Equals("userapproval", StringComparison.OrdinalIgnoreCase)).ToList();
}
else
{
notifications = notifications.Where(n => query.Type.Contains(n.Type, StringComparer.OrdinalIgnoreCase)).ToList();
}
}
}
if (query.Stores?.Length > 0)
{
notifications = notifications.Where(n => !string.IsNullOrEmpty(n.StoreId) && query.Stores.Contains(n.StoreId, StringComparer.OrdinalIgnoreCase)).ToList();
}
return notifications;
}
public async Task<List<NotificationViewModel>> ToggleSeen(NotificationsQuery notificationsQuery, bool? setSeen)
{
await using var dbContext = _factory.CreateContext();
@ -195,5 +219,8 @@ namespace BTCPayServer.Services.Notifications
public int? Skip { get; set; }
public int? Take { get; set; }
public bool? Seen { get; set; }
public string SearchText { get; set; }
public string[] Type { get; set; }
public string[] Stores { get; set; }
}
}

View file

@ -58,6 +58,11 @@ namespace BTCPayServer.Services.Notifications
}
}
public BaseNotification GetBaseNotification(NotificationData notificationData)
{
return notificationData.HasTypedBlob<BaseNotification>().GetBlob();
}
private async Task<string[]> GetUsers(INotificationScope scope, string notificationIdentifier)
{
await using var ctx = _contextFactory.CreateContext();

View file

@ -1,38 +1,137 @@
@model BTCPayServer.Models.NotificationViewModels.IndexViewModel
@model BTCPayServer.Models.NotificationViewModels.NotificationIndexViewModel
@{
ViewData["Title"] = "Notifications";
string status = ViewBag.Status;
var statusFilterCount = CountArrayFilter("type");
var storesFilterCount = CountArrayFilter("store");
}
<partial name="_StatusMessage" />
@functions
{
private int CountArrayFilter(string type) =>
Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0;
<div class="d-flex flex-wrap align-items-center justify-content-between mb-2">
private bool HasArrayFilter(string type, string key = null) =>
Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key));
}
@section PageHeadContent
{
<style>
.dropdown > .btn {
min-width: 7rem;
padding-left: 1rem;
text-align: left;
}
@@media (max-width: 568px) {
#SearchText {
width: 100%;
}
}
</style>
}
<div class="sticky-header">
<h2 class="my-1">@ViewData["Title"]</h2>
<a id="NotificationSettings" asp-controller="UIManage" asp-action="NotificationSettings" class="btn btn-secondary">
<vc:icon symbol="settings" />
<a id="NotificationSettings" asp-controller="UIManage" asp-action="NotificationSettings" class="btn btn-secondary d-flex align-items-center">
<vc:icon symbol="nav-store-settings" />
</a>
</div>
<partial name="_StatusMessage" />
<form asp-route-status="@Model.Status" class="d-flex flex-wrap align-items-center gap-md-4 gap-sm-0 mb-4 col-xxl-8" asp-action="Index" method="get">
<input asp-for="Count" type="hidden" />
<input asp-for="TimezoneOffset" type="hidden" />
<input type="hidden" asp-for="Status" value="@Model.Status" />
<div class="col-12 col-md-6 col-lg-4 mb-3 mb-md-0">
<input asp-for="SearchText" class="form-control" placeholder="Search…" />
</div>
<div class="btn-group col-12 col-md-auto mb-3 mb-md-0" role="group" aria-label="View Notification">
<a class="btn @((status == "All") ? "btn-primary" : "btn-outline-secondary")"
asp-controller="UINotifications"
asp-action="Index"
asp-route-Status="All">
All
</a>
<a class="btn @((status == "Unread") ? "btn-primary" : "btn-outline-secondary")"
asp-controller="UINotifications"
asp-action="Index"
asp-route-Status="Unread">
Unread
</a>
</div>
<div class="dropdown col-12 col-md-auto mb-3 mb-md-0">
<button id="StatusOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret w-100 w-md-auto" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (statusFilterCount > 0)
{
<span>@statusFilterCount Type</span>
}
else
{
<span>All Type</span>
}
</button>
<div class="dropdown-menu" aria-labelledby="StatusOptionsToggle">
<a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "invoicestate")" class="dropdown-item @(HasArrayFilter("type", "invoicestate") ? "custom-active" : "")">Invoice</a>
<a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "payout")" class="dropdown-item @(HasArrayFilter("type", "payout") ? "custom-active" : "")">Payouts</a>
<a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "newversion")" class="dropdown-item @(HasArrayFilter("type", "newversion") ? "custom-active" : "")">New Version</a>
<a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "pluginupdate")" class="dropdown-item @(HasArrayFilter("type", "pluginupdate") ? "custom-active" : "")">Plugin Updates</a>
<a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "userupdate")" class="dropdown-item @(HasArrayFilter("type", "userupdate") ? "custom-active" : "")">User Updates</a>
</div>
</div>
<div class="dropdown col-12 col-md-auto">
<button id="StoresOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret w-100 w-md-auto" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (storesFilterCount > 0)
{
<span>@storesFilterCount Store</span>
}
else
{
<span>All Stores</span>
}
</button>
<div class="dropdown-menu" aria-labelledby="StoresOptionsToggle">
@foreach (var store in Model.Stores)
{
<a asp-action="Index"
asp-route-count="@Model.Count"
asp-route-status="@Model.Status"
asp-route-searchTerm="@Model.Search.Toggle("store", store.Id)"
class="dropdown-item @(HasArrayFilter("store", store.Id) ? "custom-active" : "")">
@store.StoreName
</a>
}
</div>
</div>
</form>
@if (Model.Items.Count > 0)
{
<form method="post" asp-action="MassAction">
@if (Model.Items.Any())
{
<div class="table-responsive-md">
<div class="table-responsive">
<table class="table table-hover mass-action">
<thead class="mass-action-head">
<tr>
<th class="mass-action-select-col only-for-js">
<input name="selectedItems" type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th>Message</th>
<th class="date-col">
<div class="d-flex align-items-center gap-1">
Date
<button type="button" class="btn btn-link p-0 switch-time-format" title="Switch date format">
<button type="button" class="btn btn-link p-0 switch-time-format only-for-js" title="Switch date format">
<vc:icon symbol="time" />
</button>
</div>
</th>
<th>Message</th>
<th></th>
<th class="text-end text-success">Actions</th>
</tr>
</thead>
<thead class="mass-action-actions">
@ -40,30 +139,31 @@
<th class="mass-action-select-col only-for-js">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th colspan="6">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<strong class="mass-action-selected-count">0</strong>
selected
</div>
<th colspan="3">
<div class="d-flex flex-wrap align-items-center justify-content-start gap-3">
<div class="d-inline-flex align-items-center gap-3">
<button type="submit" name="command" value="mark-seen" class="btn btn-link gap-1">
<vc:icon symbol="actions-show" />
Mark seen
</button>
<button type="submit" name="command" value="mark-unseen" class="btn btn-link gap-1">
<vc:icon symbol="actions-hide" />
Mark unseen
</button>
@if (Model.Status == "Unread")
{
<button type="submit" name="command" value="mark-seen" class="btn btn-link gap-1">
<vc:icon symbol="actions-show" />
Mark as seen
</button>
}
<button type="submit" name="command" value="delete" class="btn btn-link gap-1">
<vc:icon symbol="actions-trash" />
Delete
</button>
</div>
<div class="vr"></div>
<div>
<strong class="mass-action-selected-count">0</strong>
selected
</div>
</div>
</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
@ -71,19 +171,23 @@
<td class="only-for-js mass-action-select-col">
<input name="selectedItems" type="checkbox" class="form-check-input mass-action-select" value="@item.Id" />
</td>
<td class="date-col">@item.Created.ToBrowserDate()</td>
<td>
@item.Body
</td>
<td class="date-col fw-normal">@item.Created.ToTimeAgo()</td>
<td class="text-end">
<div class="d-inline-flex align-items-center gap-3">
<button class="btn btn-link p-0 btn-toggle-seen" type="submit" name="command" value="flip-individual:@(item.Id)">
<span>Mark</span>&nbsp;<span class="seen-text"></span>
</button>
@if (!string.IsNullOrEmpty(item.ActionLink))
{
<a href="@item.ActionLink" class="btn btn-link p-0" rel="noreferrer noopener">Details</a>
}
@if (!item.Seen)
{
<button class="btn btn-link p-0 btn-toggle-seen text-nowrap" type="submit" name="command" value="flip-individual:@(item.Id)">
<span>Mark as seen</span>
</button>
}
</div>
</td>
</tr>
@ -103,20 +207,15 @@ else
</p>
}
<style>
.notification-row.loading {
cursor: wait;
pointer-events: none;
}
.seen-text::after {
content: "seen";
}
tr.seen td .seen-text::after {
content: "unseen";
}
tr td {
font-weight: bold;
}

View file

@ -128,6 +128,11 @@ a.unobtrusive-link {
transform: rotate(-180deg);
}
/* Time format button */
.switch-time-format {
line-height: 1;
}
/* Icon and text, used for warnings of additional info text. Adjust spacing and color via utility classes. */
.info-note {
display: inline-flex;