Merge pull request #1754 from btcpayserver/feat/prefs-generalization

Cookie Preferences Generalization, applying same style to ListInvoices and Payment Requests
This commit is contained in:
Nicolas Dorier 2020-07-31 16:12:10 +09:00 committed by GitHub
commit 8e2728902a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 278 additions and 221 deletions

View file

@ -1357,7 +1357,7 @@ namespace BTCPayServer.Tests
{
var result =
(Models.InvoicingModels.InvoicesModel)((ViewResult)acc.GetController<InvoiceController>()
.ListInvoices(filter).Result).Model;
.ListInvoices(new InvoicesModel { SearchTerm = filter }).Result).Model;
Assert.Equal(expected, result.Invoices.Any(i => i.InvoiceId == invoiceId));
}

View file

@ -29,7 +29,6 @@ using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitpayClient;
using NBXplorer;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using StoreData = BTCPayServer.Data.StoreData;
@ -598,55 +597,23 @@ namespace BTCPayServer.Controllers
return Ok("{}");
}
public class InvoicePreference
{
public int? TimezoneOffset { get; set; }
public string SearchTerm { get; set; }
}
[HttpGet]
[Route("invoices")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[BitpayAPIConstraint(false)]
public async Task<IActionResult> ListInvoices(string searchTerm = null, int skip = 0, int count = 50, int? timezoneOffset = null)
public async Task<IActionResult> ListInvoices(InvoicesModel model = null)
{
// If the user enter an empty searchTerm, then the variable will be null and not empty string
// but we want searchTerm to be null only if the user is browsing the page via some link
// NOT if the user entered some empty search
searchTerm = searchTerm is string ? searchTerm :
this.Request.Query.ContainsKey(nameof(searchTerm)) ? string.Empty :
null;
if (searchTerm is null)
{
if (this.Request.Cookies.TryGetValue("ListInvoicePreferences", out var str))
{
var preferences = JsonConvert.DeserializeObject<InvoicePreference>(str);
searchTerm = preferences.SearchTerm;
timezoneOffset = timezoneOffset is int v ? v : preferences.TimezoneOffset;
}
}
else
{
var preferences = new InvoicePreference();
preferences.SearchTerm = searchTerm;
preferences.TimezoneOffset = timezoneOffset;
this.Response.Cookies.Append("ListInvoicePreferences", JsonConvert.SerializeObject(preferences));
}
var fs = new SearchString(searchTerm);
model = this.ParseListQuery(model ?? new InvoicesModel());
var fs = new SearchString(model.SearchTerm);
var storeIds = fs.GetFilterArray("storeid") != null ? fs.GetFilterArray("storeid") : new List<string>().ToArray();
var model = new InvoicesModel
{
SearchTerm = searchTerm,
Skip = skip,
Count = count,
StoreIds = storeIds,
TimezoneOffset = timezoneOffset
};
InvoiceQuery invoiceQuery = GetInvoiceQuery(searchTerm, timezoneOffset ?? 0);
model.StoreIds = storeIds;
InvoiceQuery invoiceQuery = GetInvoiceQuery(model.SearchTerm, model.TimezoneOffset ?? 0);
var counting = _InvoiceRepository.GetInvoicesTotal(invoiceQuery);
invoiceQuery.Count = count;
invoiceQuery.Skip = skip;
invoiceQuery.Count = model.Count;
invoiceQuery.Skip = model.Skip;
var list = await _InvoiceRepository.GetInvoices(invoiceQuery);
foreach (var invoice in list)

View file

@ -61,23 +61,22 @@ namespace BTCPayServer.Controllers
[HttpGet]
[Route("")]
[BitpayAPIConstraint(false)]
public async Task<IActionResult> GetPaymentRequests(int skip = 0, int count = 50, bool includeArchived = false)
public async Task<IActionResult> GetPaymentRequests(ListPaymentRequestsViewModel model = null)
{
model = this.ParseListQuery(model ?? new ListPaymentRequestsViewModel());
var includeArchived = new SearchString(model.SearchTerm).GetFilterBool("includearchived") == true;
var result = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery()
{
UserId = GetUserId(),
Skip = skip,
Count = count,
Skip = model.Skip,
Count = model.Count,
IncludeArchived = includeArchived
});
return View(new ListPaymentRequestsViewModel()
{
IncludeArchived = includeArchived,
Skip = skip,
Count = count,
Total = result.Total,
Items = result.Items.Select(data => new ViewPaymentRequestViewModel(data)).ToList()
});
model.Total = result.Total;
model.Items = result.Items.Select(data => new ViewPaymentRequestViewModel(data)).ToList();
return View(model);
}
[HttpGet]

View file

@ -0,0 +1,93 @@
using System;
using System.Reflection;
using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Models.PaymentRequestViewModels;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace BTCPayServer
{
// Classes here remember users preferences on certain pages and store them in unified blob cookie "UserPreferCookie"
public static class ControllerBaseExtension
{
public static T ParseListQuery<T>(this ControllerBase ctrl, T model) where T : BasePagingViewModel
{
PropertyInfo prop;
if (model is InvoicesModel)
prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.InvoicesQuery));
else if (model is ListPaymentRequestsViewModel)
prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.PaymentRequestsQuery));
else
throw new Exception("Unsupported BasePagingViewModel for cookie user preferences saving");
return ProcessParse(ctrl, model, prop);
}
private static T ProcessParse<T>(ControllerBase ctrl, T model, PropertyInfo prop) where T : BasePagingViewModel
{
var prefCookie = parsePrefCookie(ctrl);
// If the user enter an empty searchTerm, then the variable will be null and not empty string
// but we want searchTerm to be null only if the user is browsing the page via some link
// NOT if the user entered some empty search
var searchTerm = model.SearchTerm;
searchTerm = searchTerm is string ? searchTerm :
ctrl.Request.Query.ContainsKey(nameof(searchTerm)) ? string.Empty :
null;
if (searchTerm is null)
{
var section = prop.GetValue(prefCookie) as ListQueryDataHolder;
if (section != null && !String.IsNullOrEmpty(section.SearchTerm))
{
model.SearchTerm = section.SearchTerm;
model.TimezoneOffset = section.TimezoneOffset ?? 0;
}
}
else
{
prop.SetValue(prefCookie, new ListQueryDataHolder(model.SearchTerm, model.TimezoneOffset));
ctrl.Response.Cookies.Append(nameof(UserPrefsCookie), JsonConvert.SerializeObject(prefCookie));
}
return model;
}
private static UserPrefsCookie parsePrefCookie(ControllerBase ctrl)
{
var prefCookie = new UserPrefsCookie();
ctrl.Request.Cookies.TryGetValue(nameof(UserPrefsCookie), out var strPrefCookie);
if (!String.IsNullOrEmpty(strPrefCookie))
{
try
{
prefCookie = JsonConvert.DeserializeObject<UserPrefsCookie>(strPrefCookie);
}
catch { /* ignore cookie deserialization failures */ }
}
return prefCookie;
}
class UserPrefsCookie
{
public ListQueryDataHolder InvoicesQuery { get; set; }
public ListQueryDataHolder PaymentRequestsQuery { get; set; }
}
class ListQueryDataHolder
{
public ListQueryDataHolder() { }
public ListQueryDataHolder(string searchTerm, int? timezoneOffset)
{
SearchTerm = searchTerm;
TimezoneOffset = timezoneOffset;
}
public int? TimezoneOffset { get; set; }
public string SearchTerm { get; set; }
}
}
}

View file

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace BTCPayServer.Models
{
public abstract class BasePagingViewModel
{
public int Skip { get; set; } = 0;
public int Count { get; set; } = 50;
public int Total { get; set; }
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string SearchTerm { get; set; }
public int? TimezoneOffset { get; set; }
}
}

View file

@ -4,13 +4,8 @@ using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Models.InvoicingModels
{
public class InvoicesModel
public class InvoicesModel : BasePagingViewModel
{
public int Skip { get; set; }
public int Count { get; set; }
public int Total { get; set; }
public string SearchTerm { get; set; }
public int? TimezoneOffset { get; set; }
public List<InvoiceModel> Invoices { get; set; } = new List<InvoiceModel>();
public string[] StoreIds { get; set; }
}

View file

@ -8,15 +8,10 @@ using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
namespace BTCPayServer.Models.PaymentRequestViewModels
{
public class ListPaymentRequestsViewModel
public class ListPaymentRequestsViewModel : BasePagingViewModel
{
public int Skip { get; set; }
public int Count { get; set; }
public List<ViewPaymentRequestViewModel> Items { get; set; }
public int Total { get; set; }
public bool IncludeArchived { get; set; }
}
public class UpdatePaymentRequestViewModel

View file

@ -58,6 +58,16 @@ namespace BTCPayServer
return null;
var val = Filters[key].First();
// handle special string values
if (val == "-24h")
return DateTimeOffset.UtcNow.AddHours(-24).AddMinutes(timezoneOffset);
else if (val == "-3d")
return DateTimeOffset.UtcNow.AddDays(-3).AddMinutes(timezoneOffset);
else if (val == "-7d")
return DateTimeOffset.UtcNow.AddDays(-7).AddMinutes(timezoneOffset);
// default parsing logic
var success = DateTimeOffset.TryParse(val, null as IFormatProvider, DateTimeStyles.AssumeUniversal, out var r);
if (success)
{

View file

@ -45,7 +45,7 @@
</div>
</div>
<form asp-action="ListInvoices" method="get" style="float:right;">
<form asp-action="ListInvoices" method="get" class="pull-right">
<input type="hidden" asp-for="Count" />
<div class="input-group">
<input asp-for="TimezoneOffset" type="hidden" />
@ -74,9 +74,9 @@
<a class="dropdown-item" href="/invoices?Count=@Model.Count&SearchTerm=unusual%3Atrue@{@storeIds}">Unusual Invoices</a>
<a class="dropdown-item" href="/invoices?Count=@Model.Count&SearchTerm=includearchived%3Atrue@{@storeIds}">Archived Invoices</a>
<div role="separator" class="dropdown-divider"></div>
<a class="dropdown-item last24" href="/invoices?Count=@Model.Count&timezoneoffset=0&SearchTerm=startDate%3Alast24@{@storeIds}">Last 24 hours</a>
<a class="dropdown-item last72" href="/invoices?Count=@Model.Count&timezoneoffset=0&SearchTerm=startDate%3Alast72@{@storeIds}">Last 3 days</a>
<a class="dropdown-item last168" href="/invoices?Count=@Model.Count&timezoneoffset=0&SearchTerm=startDate%3Alast168@{@storeIds}">Last 7 days</a>
<a class="dropdown-item" href="/invoices?Count=@Model.Count&timezoneoffset=0&SearchTerm=startDate%3A-24h@{@storeIds}">Last 24 hours</a>
<a class="dropdown-item" href="/invoices?Count=@Model.Count&timezoneoffset=0&SearchTerm=startDate%3A-3d@{@storeIds}">Last 3 days</a>
<a class="dropdown-item" href="/invoices?Count=@Model.Count&timezoneoffset=0&SearchTerm=startDate%3A-7d@{@storeIds}">Last 7 days</a>
<button type="button" class="dropdown-item" data-toggle="modal" data-target="#customRangeModal" data-backdrop="static">Custom Range</button>
<div role="separator" class="dropdown-divider"></div>
<a class="dropdown-item" href="/invoices?SearchTerm=">Unfiltered</a>
@ -88,7 +88,7 @@
<form method="post" id="MassAction" asp-action="MassAction">
<div class="row button-row">
<div class="col-lg-24">
<div class="col-lg-12 pl-0">
<a asp-action="CreateInvoice" class="btn btn-primary" role="button" id="CreateNewInvoice"><span class="fa fa-plus"></span> Create a new invoice</a>
<span>
@ -313,73 +313,7 @@
}
</tbody>
</table>
<nav aria-label="..." class="w-100">
@if (Model.Total != 0)
{
<ul class="pagination float-left">
<li class="page-item @(Model.Skip == 0 ? "disabled" : null)">
<a class="page-link" tabindex="-1" href="@ListInvoicesPage(-1, Model.Count)">&laquo;</a>
</li>
<li class="page-item disabled">
@if (Model.Total <= Model.Count)
{
<span class="page-link">
1@Model.Invoices.Count
</span>
}
else
{
<span class="page-link">
@(Model.Skip + 1)@(Model.Skip + Model.Invoices.Count), Total: @Model.Total
</span>
}
</li>
<li class="page-item @(Model.Total > (Model.Skip + Model.Invoices.Count) ? null : "disabled")">
<a class="page-link" href="@ListInvoicesPage(1, Model.Count)">&raquo;</a>
</li>
</ul>
}
<ul class="pagination float-right">
<li class="page-item disabled">
<span class="page-link">Page Size:</span>
</li>
<li class="page-item @(Model.Count == 50 ? "active" : null)">
<a class="page-link" href="@ListInvoicesPage(0, 50)">50</a>
</li>
<li class="page-item @(Model.Count == 100 ? "active" : null)">
<a class="page-link" href="@ListInvoicesPage(0, 100)">100</a>
</li>
<li class="page-item @(Model.Count == 250 ? "active" : null)">
<a class="page-link" href="@ListInvoicesPage(0, 250)">250</a>
</li>
<li class="page-item @(Model.Count == 500 ? "active" : null)">
<a class="page-link" href="@ListInvoicesPage(0, 500)">500</a>
</li>
</ul>
</nav>
@{
string ListInvoicesPage(int prevNext, int count)
{
var skip = Model.Skip;
if (prevNext == -1)
{
skip = Math.Max(0, Model.Skip - Model.Count);
}
else if (prevNext == 1)
{
skip = Model.Skip + count;
}
var act = Url.Action("ListInvoices", new
{
searchTerm = Model.SearchTerm,
skip = skip,
count = count,
});
return act;
}
}
<partial name="_TableFooterPager" />
</div>
</div>
</form>
@ -387,14 +321,9 @@
<script type="text/javascript">
$(function () {
var timezoneOffset = new Date().getTimezoneOffset();
$("#TimezoneOffset").val(timezoneOffset);
$(".export-link, a.dropdown-item").each(function () {
this.href = this.href.replace("timezoneoffset=0", "timezoneoffset=" + timezoneOffset);
});
$("a.last24").each(function () { this.href = this.href.replace("last24", getDateStringWithOffset(24)); });
$("a.last72").each(function () { this.href = this.href.replace("last72", getDateStringWithOffset(72)); });
$("a.last168").each(function () { this.href = this.href.replace("last168", getDateStringWithOffset(168)); });
});

View file

@ -10,7 +10,7 @@
{
<div class="row">
<div class="col-lg-12 text-center">
<partial name="_StatusMessage"/>
<partial name="_StatusMessage" />
</div>
</div>
}
@ -18,10 +18,35 @@
<div class="col-lg-12 section-heading">
<h2>Payment Requests</h2>
<hr class="primary">
<p>Create, search or pay an payment request.</p>
</div>
</div>
<form asp-action="GetPaymentRequests" method="get" class="pull-right">
<input type="hidden" asp-for="Count" />
<div class="input-group">
<input asp-for="TimezoneOffset" type="hidden" />
<input asp-for="SearchTerm" class="form-control" style="width:300px;" />
<div class="input-group-append">
<button type="submit" class="btn btn-primary" title="Search invoice">
<span class="fa fa-search"></span> Search
</button>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="sr-only">Toggle Dropdown</span>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" href="/payment-requests?Count=@Model.Count&SearchTerm=includearchived%3Atrue">Include Archived Payment Reqs</a>
<div role="separator" class="dropdown-divider"></div>
<a class="dropdown-item" href="/payment-requests?SearchTerm=">Unfiltered</a>
</div>
</div>
</div>
<span asp-validation-for="SearchTerm" class="text-danger"></span>
</form>
<div class="row button-row">
<div class="col-lg-12">
<div class="col-lg-12 pl-0">
<a asp-action="EditPaymentRequest" class="btn btn-primary" role="button" id="CreatePaymentRequest"><span class="fa fa-plus"></span> Create a new payment request</a>
<a href="https://docs.btcpayserver.org/PaymentRequests/" target="_blank">
<span class="fa fa-question-circle-o" title="More information..."></span>
@ -32,86 +57,41 @@
<div class="col-lg-12">
<table class="table table-sm table-responsive-md">
<thead>
<tr>
<th>Title</th>
<th>Expiry</th>
<th class="text-right">Price</th>
<th class="text-right">Status</th>
<th class="text-right">Actions</th>
</tr>
<tr>
<th>Title</th>
<th>Expiry</th>
<th class="text-right">Price</th>
<th class="text-right">Status</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td>@item.Title</td>
<td>@(item.ExpiryDate?.ToString("g") ?? "No Expiry")</td>
<td class="text-right">@item.Amount @item.Currency</td>
<td class="text-right">@item.Status</td>
<td class="text-right">
<a asp-action="EditPaymentRequest" asp-route-id="@item.Id">Edit</a>
<span> - </span>
<a asp-action="ViewPaymentRequest" asp-route-id="@item.Id">View</a>
<span> - </span>
<a target="_blank" asp-action="ListInvoices" asp-controller="Invoice" asp-route-searchterm="@($"orderid:{PaymentRequestRepository.GetOrderIdForPaymentRequest(item.Id)}")">Invoices</a>
<span> - </span>
<a target="_blank" asp-action="PayPaymentRequest" asp-route-id="@item.Id">Pay</a>
<span> - </span>
<a target="_blank" asp-action="ClonePaymentRequest" asp-route-id="@item.Id">Clone</a>
<span> - </span>
<a asp-action="TogglePaymentRequestArchival" asp-route-id="@item.Id">@(item.Archived ? "Unarchive" : "Archive")</a>
</td>
</tr>
}
@foreach (var item in Model.Items)
{
<tr>
<td>@item.Title</td>
<td>@(item.ExpiryDate?.ToString("g") ?? "No Expiry")</td>
<td class="text-right">@item.Amount @item.Currency</td>
<td class="text-right">@item.Status</td>
<td class="text-right">
<a asp-action="EditPaymentRequest" asp-route-id="@item.Id">Edit</a>
<span> - </span>
<a asp-action="ViewPaymentRequest" asp-route-id="@item.Id">View</a>
<span> - </span>
<a target="_blank" asp-action="ListInvoices" asp-controller="Invoice" asp-route-searchterm="@($"orderid:{PaymentRequestRepository.GetOrderIdForPaymentRequest(item.Id)}")">Invoices</a>
<span> - </span>
<a target="_blank" asp-action="PayPaymentRequest" asp-route-id="@item.Id">Pay</a>
<span> - </span>
<a target="_blank" asp-action="ClonePaymentRequest" asp-route-id="@item.Id">Clone</a>
<span> - </span>
<a asp-action="TogglePaymentRequestArchival" asp-route-id="@item.Id">@(item.Archived ? "Unarchive" : "Archive")</a>
</td>
</tr>
}
</tbody>
</table>
<div class="d-flex ">
<nav aria-label="...">
<ul class="pagination">
<li class="page-item @(Model.Skip == 0 ? "disabled" : null)">
<a class="page-link" tabindex="-1" href="@Url.Action("GetPaymentRequests", new
{
skip = Math.Max(0, Model.Skip - Model.Count),
count = Model.Count,
})">
Previous
</a>
</li>
<li class="page-item disabled">
<span class="page-link">@(Model.Skip + 1) to @(Model.Skip + Model.Count) of @Model.Total</span>
</li>
<li class="page-item @(Model.Total > (Model.Skip + Model.Count) ? null : "disabled")">
<a class="page-link" href="@Url.Action("GetPaymentRequests", new
{
skip = Model.Skip + Model.Count,
count = Model.Count,
})">
Next
</a>
</li>
</ul>
</nav>
<a class="ml-2 mt-1" href="@Url.Action("GetPaymentRequests", new
{
skip = Model.Skip,
count = Model.Count,
includeArchived = !Model.IncludeArchived
})">
@if (Model.IncludeArchived)
{
<span> Hide archived payment requests. </span>
}
else
{
<span> Show archived payment requests.</span>
}
</a>
</div>
<partial name="_TableFooterPager" />
</div>
</div>
</div>

View file

@ -0,0 +1,70 @@
@model BasePagingViewModel
<nav aria-label="..." class="w-100">
@if (Model.Total != 0)
{
<ul class="pagination float-left">
<li class="page-item @(Model.Skip == 0 ? "disabled" : null)">
<a class="page-link" tabindex="-1" href="@NavigatePages(-1, Model.Count)">&laquo;</a>
</li>
<li class="page-item disabled">
@if (Model.Total <= Model.Count)
{
<span class="page-link">
1@Model.Total
</span>
}
else
{
<span class="page-link">
@(Model.Skip + 1)@(Model.Skip + Model.Count), Total: @Model.Total
</span>
}
</li>
<li class="page-item @(Model.Total > (Model.Skip + Model.Count) ? null : "disabled")">
<a class="page-link" href="@NavigatePages(1, Model.Count)">&raquo;</a>
</li>
</ul>
}
<ul class="pagination float-right">
<li class="page-item disabled">
<span class="page-link">Page Size:</span>
</li>
<li class="page-item @(Model.Count == 50 ? "active" : null)">
<a class="page-link" href="@NavigatePages(0, 50)">50</a>
</li>
<li class="page-item @(Model.Count == 100 ? "active" : null)">
<a class="page-link" href="@NavigatePages(0, 100)">100</a>
</li>
<li class="page-item @(Model.Count == 250 ? "active" : null)">
<a class="page-link" href="@NavigatePages(0, 250)">250</a>
</li>
<li class="page-item @(Model.Count == 500 ? "active" : null)">
<a class="page-link" href="@NavigatePages(0, 500)">500</a>
</li>
</ul>
</nav>
@{
string NavigatePages(int prevNext, int count)
{
var skip = Model.Skip;
if (prevNext == -1)
{
skip = Math.Max(0, Model.Skip - Model.Count);
}
else if (prevNext == 1)
{
skip = Model.Skip + count;
}
var act = Url.Action(null, new
{
searchTerm = Model.SearchTerm,
timezoneOffset = Model.TimezoneOffset,
skip = skip,
count = count,
});
return act;
}
}

View file

@ -1,4 +1,9 @@
$(function () {
$(function () {
// initialize timezone offset value if field is present in page
var timezoneOffset = new Date().getTimezoneOffset();
$("#TimezoneOffset").val(timezoneOffset);
// localize all elements that have localizeDate class
$(".localizeDate").each(function (index) {
var serverDate = $(this).text();
var localDate = new Date(serverDate);