Skip to content

Commit

Permalink
Improve invoice filtering UI
Browse files Browse the repository at this point in the history
Closes #3664.
  • Loading branch information
dennisreimann committed May 5, 2023
1 parent 18e34b3 commit aa49fff
Show file tree
Hide file tree
Showing 10 changed files with 265 additions and 125 deletions.
49 changes: 45 additions & 4 deletions BTCPayServer.Tests/FastTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1042,14 +1042,13 @@ public void CanUsePermission()
[Fact]
public void CanParseFilter()
{
var storeId = "6DehZnc9S7qC6TUTNWuzJ1pFsHTHvES6An21r3MjvLey";
var filter = "storeid:abc, status:abed, blabhbalh ";
var search = new SearchString(filter);
Assert.Equal("storeid:abc, status:abed, blabhbalh", search.ToString());
Assert.Equal("blabhbalh", search.TextSearch);
Assert.Single(search.Filters["storeid"]);
Assert.Single(search.Filters["status"]);
Assert.Equal("abc", search.Filters["storeid"].First());
Assert.Equal("abed", search.Filters["status"].First());
Assert.Single(search.Filters["storeid"], "abc");
Assert.Single(search.Filters["status"], "abed");

filter = "status:abed, status:abed2";
search = new SearchString(filter);
Expand All @@ -1064,6 +1063,48 @@ public void CanParseFilter()
search = new SearchString(filter);
Assert.Equal("2019-04-25 01:00 AM", search.Filters["startdate"].First());
Assert.Equal("hekki", search.TextSearch);

// modify search
filter = $"status:settled,exceptionstatus:paidLate,unusual:true, fulltext searchterm, storeid:{storeId},startdate:2019-04-25 01:00:00";
search = new SearchString(filter);
Assert.Equal(filter, search.ToString());
Assert.Equal("fulltext searchterm", search.TextSearch);
Assert.Single(search.Filters["storeid"], storeId);
Assert.Single(search.Filters["status"], "settled");
Assert.Single(search.Filters["exceptionstatus"], "paidLate");
Assert.Single(search.Filters["unusual"], "true");

// toggle off bool with same value
var modified = new SearchString(search.Toggle("unusual", "true"));
Assert.Null(modified.GetFilterBool("unusual"));

// add to array
modified = new SearchString(modified.Toggle("status", "processing"));
var statusArray = modified.GetFilterArray("status");
Assert.Equal(2, statusArray.Length);
Assert.Contains("processing", statusArray);
Assert.Contains("settled", statusArray);

// toggle off array with same value
modified = new SearchString(modified.Toggle("status", "settled"));
statusArray = modified.GetFilterArray("status");
Assert.Single(statusArray, "processing");

// toggle off array with null value
modified = new SearchString(modified.Toggle("status", null));
Assert.Null(modified.GetFilterArray("status"));

// toggle off date with null value
modified = new SearchString(modified.Toggle("startdate", "-7d"));
Assert.Single(modified.GetFilterArray("startdate"), "-7d");
modified = new SearchString(modified.Toggle("startdate", null));
Assert.Null(modified.GetFilterArray("startdate"));

// toggle off date with same value
modified = new SearchString(modified.Toggle("enddate", "-7d"));
Assert.Single(modified.GetFilterArray("enddate"), "-7d");
modified = new SearchString(modified.Toggle("enddate", "-7d"));
Assert.Null(modified.GetFilterArray("enddate"));
}

[Fact]
Expand Down
4 changes: 2 additions & 2 deletions BTCPayServer.Tests/SeleniumTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -718,8 +718,8 @@ public async Task CanCreateStores()
Assert.DoesNotContain(invoiceId, s.Driver.PageSource);

// unarchive via list
s.Driver.FindElement(By.Id("SearchOptionsToggle")).Click();
s.Driver.FindElement(By.Id("SearchOptionsIncludeArchived")).Click();
s.Driver.FindElement(By.Id("StatusOptionsToggle")).Click();
s.Driver.FindElement(By.Id("StatusOptionsIncludeArchived")).Click();
Assert.Contains(invoiceId, s.Driver.PageSource);
s.Driver.FindElement(By.CssSelector($".selector[value=\"{invoiceId}\"]")).Click();
s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click();
Expand Down
46 changes: 22 additions & 24 deletions BTCPayServer/Controllers/UIInvoiceController.UI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1006,34 +1006,34 @@ public async Task<IActionResult> UpdateCustomer(string invoiceId, [FromBody] Upd
public async Task<IActionResult> ListInvoices(InvoicesModel? model = null)
{
model = this.ParseListQuery(model ?? new InvoicesModel());
var fs = new SearchString(model.SearchTerm);
var timezoneOffset = model.TimezoneOffset ?? 0;
var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}";
var fs = new SearchString(searchTerm, timezoneOffset);
string? storeId = model.StoreId;
var storeIds = new HashSet<string>();
if (fs.GetFilterArray("storeid") is string[] l)
{
foreach (var i in l)
storeIds.Add(i);
}
if (storeId is not null)
{
storeIds.Add(storeId);
model.StoreId = storeId;
}
model.StoreIds = storeIds.ToArray();

InvoiceQuery invoiceQuery = GetInvoiceQuery(model.SearchTerm, model.TimezoneOffset ?? 0);
invoiceQuery.StoreId = model.StoreIds;
if (fs.GetFilterArray("storeid") is { } l)
{
foreach (var i in l)
storeIds.Add(i);
}
model.Search = fs;
model.SearchText = fs.TextSearch;

InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset);
invoiceQuery.StoreId = storeIds.ToArray();
invoiceQuery.Take = model.Count;
invoiceQuery.Skip = model.Skip;
invoiceQuery.IncludeRefunds = true;
var list = await _InvoiceRepository.GetInvoices(invoiceQuery);

model.IncludeArchived = invoiceQuery.IncludeArchived;

foreach (var invoice in list)
{
var state = invoice.GetInvoiceState();
model.Invoices.Add(new InvoiceModel()
model.Invoices.Add(new InvoiceModel
{
Status = state,
ShowCheckout = invoice.Status == InvoiceStatusLegacy.New,
Expand All @@ -1052,10 +1052,9 @@ public async Task<IActionResult> ListInvoices(InvoicesModel? model = null)
return View(model);
}

private InvoiceQuery GetInvoiceQuery(string? searchTerm = null, int timezoneOffset = 0)
private InvoiceQuery GetInvoiceQuery(SearchString fs, int timezoneOffset = 0)
{
var fs = new SearchString(searchTerm);
var invoiceQuery = new InvoiceQuery()
return new InvoiceQuery
{
TextSearch = fs.TextSearch,
UserId = GetUserId(),
Expand All @@ -1069,7 +1068,6 @@ private InvoiceQuery GetInvoiceQuery(string? searchTerm = null, int timezoneOffs
StartDate = fs.GetFilterDate("startdate", timezoneOffset),
EndDate = fs.GetFilterDate("enddate", timezoneOffset)
};
return invoiceQuery;
}

[HttpGet]
Expand All @@ -1080,17 +1078,17 @@ public async Task<IActionResult> Export(string format, string? storeId = null, s
var model = new InvoiceExport(_CurrencyNameTable);
var fs = new SearchString(searchTerm);
var storeIds = new HashSet<string>();
if (fs.GetFilterArray("storeid") is string[] l)
{
foreach (var i in l)
storeIds.Add(i);
}
if (storeId is not null)
{
storeIds.Add(storeId);
}
if (fs.GetFilterArray("storeid") is { } l)
{
foreach (var i in l)
storeIds.Add(i);
}

InvoiceQuery invoiceQuery = GetInvoiceQuery(searchTerm, timezoneOffset);
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset);
invoiceQuery.StoreId = storeIds.ToArray();
invoiceQuery.Skip = 0;
invoiceQuery.Take = int.MaxValue;
Expand Down
2 changes: 1 addition & 1 deletion BTCPayServer/Controllers/UIPaymentRequestController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public async Task<IActionResult> GetPaymentRequests(string storeId, ListPaymentR
model = this.ParseListQuery(model ?? new ListPaymentRequestsViewModel());

var store = GetCurrentStore();
var includeArchived = new SearchString(model.SearchTerm).GetFilterBool("includearchived") == true;
var includeArchived = new SearchString(model.SearchTerm, model.TimezoneOffset ?? 0).GetFilterBool("includearchived") == true;
var result = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery
{
UserId = GetUserId(),
Expand Down
8 changes: 4 additions & 4 deletions BTCPayServer/Models/InvoicingModels/InvoicesModel.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Invoices;

namespace BTCPayServer.Models.InvoicingModels
{
public class InvoicesModel : BasePagingViewModel
{
public List<InvoiceModel> Invoices { get; set; } = new List<InvoiceModel>();
public List<InvoiceModel> Invoices { get; set; } = new ();
public override int CurrentPageCount => Invoices.Count;
public string[] StoreIds { get; set; }
public string StoreId { get; set; }
public bool IncludeArchived { get; set; }

public string SearchText { get; set; }
public SearchString Search { get; set; }
}

public class InvoiceModel
Expand Down
130 changes: 100 additions & 30 deletions BTCPayServer/SearchString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,73 +2,129 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using ExchangeSharp;

namespace BTCPayServer
{
public class SearchString
{
readonly string _OriginalString;
public SearchString(string str)
private const char FilterSeparator = ',';
private const char ValueSeparator = ':';

private readonly string _originalString;
private readonly int _timezoneOffset;

public SearchString(string str, int timezoneOffset = 0)
{
str = str ?? string.Empty;
str ??= string.Empty;
str = str.Trim();
_OriginalString = str.Trim();
TextSearch = _OriginalString;
var splitted = str.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
_originalString = str;
_timezoneOffset = timezoneOffset;
TextSearch = _originalString;
var splitted = str.Split(new [] { FilterSeparator }, StringSplitOptions.RemoveEmptyEntries);
Filters
= splitted
.Select(t => t.Split(new char[] { ':' }, 2, StringSplitOptions.RemoveEmptyEntries))
.Select(t => t.Split(new [] { ValueSeparator }, 2, StringSplitOptions.RemoveEmptyEntries))
.Where(kv => kv.Length == 2)
.Select(kv => new KeyValuePair<string, string>(kv[0].ToLowerInvariant().Trim(), kv[1]))
.Select(kv => new KeyValuePair<string, string>(UnifyKey(kv[0]), kv[1]))
.ToMultiValueDictionary(o => o.Key, o => o.Value);

var val = splitted.FirstOrDefault(a => a?.IndexOf(':', StringComparison.OrdinalIgnoreCase) == -1);
if (val != null)
TextSearch = val.Trim();
else
TextSearch = "";
var val = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1);
TextSearch = val != null ? val.Trim() : string.Empty;
}

public string TextSearch { get; private set; }

public MultiValueDictionary<string, string> Filters { get; private set; }
public MultiValueDictionary<string, string> Filters { get; }

public override string ToString()
{
return _OriginalString;
return _originalString;
}

public string Toggle(string key, string value)
{
key = UnifyKey(key);
var keyValue = $"{key}{ValueSeparator}{value}";
var prependOnInsert = string.IsNullOrEmpty(ToString()) ? string.Empty : $"{ToString()}{FilterSeparator}";
if (!ContainsFilter(key)) return Finalize($"{prependOnInsert}{keyValue}");

var boolFilter = GetFilterBool(key);
if (boolFilter != null)
{
return Finalize(ToString().Replace(keyValue, string.Empty));
}

var dateFilter = GetFilterDate(key, _timezoneOffset);
if (dateFilter != null)
{
var current = GetFilterArray(key).First();
var oldValue = $"{key}{ValueSeparator}{current}";
var newValue = string.IsNullOrEmpty(value) || current == value ? string.Empty : keyValue;
return Finalize(_originalString.Replace(oldValue, newValue));
}

var arrayFilter = GetFilterArray(key);
if (arrayFilter != null)
{
if (string.IsNullOrEmpty(value))
{
return Finalize(arrayFilter.Aggregate(ToString(), (current, filter) =>
current.Replace($"{key}{ValueSeparator}{filter}", string.Empty)));
}
return Finalize(arrayFilter.Contains(value)
? ToString().Replace(keyValue, string.Empty)
: $"{prependOnInsert}{keyValue}"
);
}

return Finalize(ToString());
}

public string WithoutSearchText()
{
return string.IsNullOrEmpty(TextSearch)
? Finalize(ToString())
: Finalize(ToString()).Replace(TextSearch, string.Empty);
}

internal string[] GetFilterArray(string key)
public string[] GetFilterArray(string key)
{
key = UnifyKey(key);
return Filters.ContainsKey(key) ? Filters[key].ToArray() : null;
}

internal bool? GetFilterBool(string key)
public bool? GetFilterBool(string key)
{
key = UnifyKey(key);
if (!Filters.ContainsKey(key))
return null;

return bool.TryParse(Filters[key].First(), out var r) ?
r : (bool?)null;
return bool.TryParse(Filters[key].First(), out var r) ? r : null;
}

internal DateTimeOffset? GetFilterDate(string key, int timezoneOffset)
public DateTimeOffset? GetFilterDate(string key, int timezoneOffset)
{
key = UnifyKey(key);
if (!Filters.ContainsKey(key))
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);
switch (val)
{
// handle special string values
case "-24h":
case "-1d":
return DateTimeOffset.UtcNow.AddDays(-1).AddMinutes(timezoneOffset);
case "-3d":
return DateTimeOffset.UtcNow.AddDays(-3).AddMinutes(timezoneOffset);
case "-7d":
return DateTimeOffset.UtcNow.AddDays(-7).AddMinutes(timezoneOffset);
}

// default parsing logic
var success = DateTimeOffset.TryParse(val, null as IFormatProvider, DateTimeStyles.AssumeUniversal, out var r);
var success = DateTimeOffset.TryParse(val, null, DateTimeStyles.AssumeUniversal, out var r);
if (success)
{
r = r.AddMinutes(timezoneOffset);
Expand All @@ -78,6 +134,20 @@ internal string[] GetFilterArray(string key)
return null;
}

internal bool ContainsFilter(string key) => Filters.ContainsKey(key);
public bool ContainsFilter(string key)
{
return Filters.ContainsKey(UnifyKey(key));
}

private string UnifyKey(string key)
{
return key.ToLowerInvariant().Trim();
}

private static string Finalize(string str)
{
var value = str.TrimStart(FilterSeparator).TrimEnd(FilterSeparator);
return string.IsNullOrEmpty(value) ? " " : value;
}
}
}

0 comments on commit aa49fff

Please sign in to comment.