Improve date range filtering and shared search components (#7424)
What changed, and why it matters
This is a large UI refactoring commit that improves date range filtering, search components, and label selectors across BTCPay Server's admin pages. It replaces timezone-offset handling with named timezone handling, restructures how search filters are parsed and stored, and adds shared view components. There is no clear security fix or vulnerability being patched; it appears to be a feature/refactoring change.
Treat as a routine feature/refactoring commit. Review the new SearchString parsing and filter command handling for potential injection or bypass issues during normal code review, but no immediate security action is warranted based on the available evidence.
Security signals we found
Large refactoring touching authentication-adjacent controllers (invoices, users, wallets, payment requests, payouts)
Removal of cookie-based user preference persistence for list queries (reduces stored user state in cookies)
Search string parsing changes that could affect query interpretation or filter bypass if improperly validated
Timezone handling changed from offset to named timezone IDs, which may affect date boundary calculations
No explicit security disclosure, CVE, or attribution in commit message or diff
Evidence from the diff
The commit refactors search and filter handling across multiple controllers and views. Key changes include: replacing TimezoneOffset integer parameters with named TimeZone identifiers; introducing shared ViewComponents (DateRangeSelector, LabelSelector, SearchStringInput, ClearAllFilters, DateColumn, DeclareDateFormatterOptions); moving filter command processing into BasePagingViewModel.GetSearch(); removing the ControllerBaseExtensions.ParseListQuery cookie-based preference persistence for list queries; and updating SearchString parsing to support UI vs non-UI filter separation and timezone-aware date parsing. The diff shows extensive test updates to match new component IDs and behavior, but no direct evidence of a security vulnerability or its fix.
Changed components
BTCPayServer/SearchString.csBTCPayServer/Models/BasePagingViewModel.csBTCPayServer/Controllers/UIInvoiceController.UI.csBTCPayServer/Controllers/UINotificationsController.csBTCPayServer/Controllers/UIPaymentRequestController.csBTCPayServer/Controllers/UIServerController.Users.csBTCPayServer/Controllers/UIStorePullPaymentsController.PullPayments.csBTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.csBTCPayServer/Components/DateRangeSelectorBTCPayServer/Components/LabelSelectorBTCPayServer/Components/SearchStringInputBTCPayServer/Components/ClearAllFiltersBTCPayServer/Extensions/UserPrefsCookie.csBTCPayServer/Extensions/ControllerBaseExtensions.csInspect captured patch +1593 / −1591
diff --git a/BTCPayServer.Abstractions/Extensions/ViewsRazor.cs b/BTCPayServer.Abstractions/Extensions/ViewsRazor.cs
index bae22d7..68cd01a 100644
--- a/BTCPayServer.Abstractions/Extensions/ViewsRazor.cs
+++ b/BTCPayServer.Abstractions/Extensions/ViewsRazor.cs
@@ -169,10 +169,11 @@ namespace BTCPayServer.Abstractions.Extensions
public static HtmlString ToBrowserDate(this DateTimeOffset date, string netFormat, string jsDateFormat = "short", string jsTimeFormat = "short")
{
+ var relative = date.ToTimeAgo();
var dateTime = date.ToString("o", CultureInfo.InvariantCulture);
var displayDate = date.ToString(netFormat, CultureInfo.InvariantCulture);
var tooltip = dateTime.Replace("T", " ");
- return new HtmlString($"<time datetime=\"{dateTime}\" data-date-style=\"{jsDateFormat}\" data-time-style=\"{jsTimeFormat}\" data-initial=\"localized\" data-bs-toggle=\"tooltip\" data-bs-title=\"{tooltip}\">{displayDate}</time>");
+ return new HtmlString($"<time datetime=\"{dateTime}\" data-relative=\"{relative}\" data-date-style=\"{jsDateFormat}\" data-time-style=\"{jsTimeFormat}\" data-initial=\"localized\" data-bs-toggle=\"tooltip\" data-bs-title=\"{tooltip}\">{displayDate}</time>");
}
public static HtmlString ToBrowserDate(this DateTimeOffset date, DateDisplayFormat format = DateDisplayFormat.Localized)
diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs
index ab5dcbf..d813fc6 100644
--- a/BTCPayServer.Tests/FastTests.cs
+++ b/BTCPayServer.Tests/FastTests.cs
@@ -21,6 +21,7 @@ using BTCPayServer.HostedServices;
using BTCPayServer.Hosting;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments;
+using BTCPayServer.Plugins.Wallets.Views.ViewModels;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@@ -1430,19 +1431,18 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
[Fact]
public void CanParseFilter()
{
+ var utc = TimeZoneInfo.Utc;
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("storeid:abc,status:abed,blabhbalh", search.ToString());
Assert.Equal("blabhbalh", search.TextSearch);
Assert.Single(search.Filters["storeid"], "abc");
Assert.Single(search.Filters["status"], "abed");
filter = "status:abed, status:abed2";
search = new SearchString(filter);
- Assert.Null(search.TextSearch);
- Assert.Null(search.TextFilters);
- Assert.Equal("status:abed, status:abed2", search.ToString());
+ Assert.Equal("status:abed,status:abed2", search.ToString());
Assert.Throws<KeyNotFoundException>(() => search.Filters["test"]);
Assert.Equal(2, search.Filters["status"].Count);
Assert.Equal("abed", search.Filters["status"].First());
@@ -1452,23 +1452,23 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
search = new SearchString(filter);
Assert.Equal("2019-04-25 01:00 AM", search.Filters["startdate"].First());
Assert.Equal("hekki", search.TextSearch);
- Assert.Equal("orderid:MYORDERID,orderid:MYORDERID_2", search.TextFilters);
- Assert.Equal("orderid:MYORDERID,orderid:MYORDERID_2,hekki", search.TextCombined);
- Assert.Equal("startdate:2019-04-25 01:00 AM", search.WithoutSearchText());
- Assert.Equal(filter, search.ToString());
+ Assert.Equal("orderid:MYORDERID,orderid:MYORDERID_2,hekki", search.ToString(SearchStringFormat.ExceptUIFilters));
+ Assert.Equal("startdate:2019-04-25 01:00 AM", search.ToString(SearchStringFormat.OnlyUIFilters));
+ Assert.Equal("startdate:2019-04-25 01:00 AM,orderid:MYORDERID,orderid:MYORDERID_2,hekki", search.ToString());
filter = "label:test,nolabel:true,direction:in, hekki";
search = new SearchString(filter);
+ search.UIFilterTypes.Add("label");
+ search.UIFilterTypes.Add("nolabel");
+ search.UIFilterTypes.Add("direction");
Assert.Equal("hekki", search.TextSearch);
- Assert.Null(search.TextFilters);
- Assert.Equal("hekki", search.TextCombined);
- Assert.Equal("label:test,nolabel:true,direction:in", search.WithoutSearchText());
+ Assert.Equal("hekki", search.ToString(SearchStringFormat.ExceptUIFilters));
Assert.Single(search.Filters["label"], "test");
Assert.Single(search.Filters["direction"], "in");
Assert.True(search.GetFilterBool("nolabel"));
// modify search
- filter = $"status:settled,exceptionstatus:paidLate,unusual:true, fulltext searchterm, storeid:{storeId},startdate:2019-04-25 01:00:00";
+ filter = $"status:settled,exceptionstatus:paidLate,unusual:true,storeid:{storeId},startdate:2019-04-25 01:00:00,fulltext searchterm";
search = new SearchString(filter);
Assert.Equal(filter, search.ToString());
Assert.Equal("fulltext searchterm", search.TextSearch);
@@ -1478,51 +1478,108 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
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"));
+ search.SetFilter("unusual", "true", true);
+ Assert.Null(search.GetFilterBool("unusual"));
// add to array
- modified = new SearchString(modified.Toggle("status", "processing"));
- var statusArray = modified.GetFilterArray("status");
+ search.SetFilter("status", "processing", toggle: true, multi: true);
+ var statusArray = search.GetFilterArray("status");
Assert.Equal(2, statusArray.Length);
+ Assert.Contains("settled", statusArray);
+ Assert.Contains("processing", statusArray);
+ search.SetFilter("status", "processing");
+ statusArray = search.GetFilterArray("status");
+ Assert.Single(statusArray);
Assert.Contains("processing", statusArray);
+ search.SetFilter("status", "settled", toggle: true, multi: false);
+ statusArray = search.GetFilterArray("status");
+ Assert.Single(statusArray);
Assert.Contains("settled", statusArray);
+ search.SetFilter("status", "processing", multi: true);
+ statusArray = search.GetFilterArray("status");
+ Assert.Equal(2, statusArray.Length);
// toggle off array with same value
- modified = new SearchString(modified.Toggle("status", "settled"));
- statusArray = modified.GetFilterArray("status");
+ search.SetFilter("status", "settled", true, true);
+ statusArray = search.GetFilterArray("status");
Assert.Single(statusArray, "processing");
// toggle off array with null value
- modified = new SearchString(modified.Toggle("status", null));
- Assert.Null(modified.GetFilterArray("status"));
+ search.SetFilter("status", null);
+ Assert.Null(search.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"));
+ search.SetFilter("startdate", "last30d");
+ Assert.Single(search.GetFilterArray("startdate"), "last30d");
+ search.SetFilter("startdate", null);
+ Assert.Null(search.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"));
+ search.SetFilter("enddate", "lastmonth");
+ Assert.Single(search.GetFilterArray("enddate"), "lastmonth");
+ search.SetFilter("enddate", "lastmonth", true);
+ Assert.Null(search.GetFilterArray("enddate"));
+
+ search = new SearchString("7,daterange:thismonth");
+ Assert.Equal("daterange:thismonth", search.ToString(SearchStringFormat.OnlyUIFilters));
+
+ var now = DateTime.UtcNow;
+ var dateRange = search.GetDateRange(utc);
+ Assert.Equal(new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero), dateRange.StartDate);
+ Assert.Null(dateRange.EndDate);
+
+ var nowUTC = DateTimeOffset.UtcNow;
+ var nowLocal = nowUTC.ToLocalTime();
+ var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
+ var nowTokyo = DateTimeOffset.UtcNow + tokyo.GetUtcOffset(nowUTC);
+ var s1 = new SearchString($"startdate:{nowUTC:O}");
+ var s2 = new SearchString($"startdate:{nowLocal:O}");
+ var s3 = new SearchString($"startdate:{nowUTC}");
+ var s4 = new SearchString($"startdate:{Regex.Replace(nowTokyo.ToString(), @"\+.*", "")},timezone={tokyo.Id}");
+ var s5 = new SearchString($"startdate:{nowUTC:u}");
+
+ void AssertEqual(SearchString a, SearchString b)
+ {
+ var d1 = a.GetFilterDate("startdate", utc)!.Value;
+ var d2 = b.GetFilterDate("startdate", utc)!.Value;
+ Assert.True((d1 - d2).TotalSeconds < 2);
+ }
- search = new SearchString("7,startdate:-7d");
- Assert.Equal("startdate:-7d", search.WithoutSearchText());
+ AssertEqual(s1, s2);
+ AssertEqual(s1, s3);
+ AssertEqual(s1, s4);
+ AssertEqual(s1, s5);
+ }
+
+ [Fact]
+ public void CanParseFilterDateWithTimeZone()
+ {
+ var utc = TimeZoneInfo.Utc;
+ var search = new SearchString("startdate:2026-01-15 10:00:00");
+ Assert.Equal(
+ new DateTimeOffset(2026, 1, 15, 10, 0, 0, TimeSpan.Zero),
+ search.GetFilterDate("startdate", utc));
+
+ var paris = TimeZoneInfo.FindSystemTimeZoneById("Europe/Paris");
+ search = new SearchString("startdate:2026-01-15 10:00:00");
+ Assert.Equal(
+ new DateTimeOffset(2026, 1, 15, 9, 0, 0, TimeSpan.Zero),
+ search.GetFilterDate("startdate", paris));
+
+ var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
+ search = new SearchString("startdate:2026-06-30 17:00:00");
+ Assert.Equal(
+ new DateTimeOffset(2026, 6, 30, 8, 0, 0, TimeSpan.Zero),
+ search.GetFilterDate("startdate", tokyo));
}
[Fact]
public void BuildWalletTransactionsFilterSeparatesTextAndStructuredTerms()
{
- var result = UIWalletsController.BuildWalletTransactionsFilter(
+ var result = BuildWalletTransactionsFilter(
"direction:out,label:primary-label,nolabel:true,startdate:2026-03-01T12:34:56",
- "abc123tx",
- "secondary-label",
- 120);
+ "abc123tx");
- Assert.Equal("abc123tx", result.SearchInputText);
Assert.Equal("abc123tx", result.SearchText);
Assert.Equal("abc123tx", result.TextSearch);
@@ -1533,41 +1590,49 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
Assert.Contains("startdate:2026-03-01T12:34:56", structuredSearchTerm);
Assert.DoesNotContain("abc123tx", structuredSearchTerm);
- Assert.Equal(["primary-label", "secondary-label"], result.LabelFilters);
+ Assert.Equal(["primary-label"], result.LabelFilters);
Assert.True(result.IncludeNoLabel);
Assert.False(result.Positive);
Assert.NotNull(result.StartDate);
Assert.True(result.HasLabelFilter);
Assert.True(result.HasFilters);
- result = UIWalletsController.BuildWalletTransactionsFilter(null, "abc", null, 120);
+ result = BuildWalletTransactionsFilter(null, "abc");
Assert.Equal(string.Empty, result.SearchTerm);
Assert.Equal("abc", result.SearchText);
- Assert.Equal("abc", result.SearchInputText);
Assert.Equal("abc", result.TextSearch);
Assert.False(result.HasLabelFilter);
Assert.True(result.HasFilters);
- result = UIWalletsController.BuildWalletTransactionsFilter("foo:bar", null, null, 120);
+ result = BuildWalletTransactionsFilter("foo:bar", null);
Assert.Equal(string.Empty, result.SearchTerm);
Assert.Equal(string.Empty, result.SearchText);
- Assert.Equal(string.Empty, result.SearchInputText);
Assert.Equal(string.Empty, result.TextSearch);
Assert.False(result.HasLabelFilter);
Assert.False(result.HasFilters);
- result = UIWalletsController.BuildWalletTransactionsFilter(string.Empty, null, null, 120);
+ result = BuildWalletTransactionsFilter(string.Empty, null);
Assert.Equal(string.Empty, result.SearchTerm);
Assert.Equal(string.Empty, result.SearchText);
- Assert.Equal(string.Empty, result.SearchInputText);
Assert.Equal(string.Empty, result.TextSearch);
Assert.False(result.HasLabelFilter);
Assert.False(result.HasFilters);
}
+ internal static UIWalletsController.WalletTransactionsFilter BuildWalletTransactionsFilter(string searchText, string searchTerm)
+ {
+ var list = new ListTransactionsViewModel()
+ {
+ SearchText = searchText,
+ SearchTerm = searchTerm,
+ };
+ var search = list.GetSearch();
+ return UIWalletsController.BuildWalletTransactionsFilter(search);
+ }
+
[Fact]
public void CanParseFingerprint()
{
diff --git a/BTCPayServer.Tests/PMO/UsersPMO.cs b/BTCPayServer.Tests/PMO/UsersPMO.cs
index af13b94..9ca8a0a 100644
--- a/BTCPayServer.Tests/PMO/UsersPMO.cs
+++ b/BTCPayServer.Tests/PMO/UsersPMO.cs
@@ -1,4 +1,5 @@
using System.Threading.Tasks;
+using static Microsoft.Playwright.Assertions;
namespace BTCPayServer.Tests.PMO;
@@ -17,4 +18,9 @@ public class UsersPMO(PlaywrightTester s)
}
private static string Row(string email) => $"tr[data-email=\"{email}\"]";
+
+ public async Task AssertActive(string email)
+ {
+ await Expect(s.Page.Locator(Row(email) + " .user-status")).ToHaveTextAsync("Active");
+ }
}
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index fd6871d..5663b2e 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -1379,7 +1379,7 @@ namespace BTCPayServer.Tests
// Filter by Status
await s.Page.ClickAsync("#StatusOptionsToggle");
- await s.Page.ClickAsync("a:has-text('Settled')");
+ await s.Page.ClickAsync("*:has-text('Settled')");
await s.Page.WaitForLoadStateAsync();
await Expect(s.Page.Locator("input[name='SearchText']"))
.ToHaveValueAsync(paymentRequestTitle);
@@ -1431,8 +1431,8 @@ namespace BTCPayServer.Tests
//Filter by Label
await s.Page.ClickAsync("#menu-item-PaymentRequests");
await s.Page.WaitForLoadStateAsync();
- await s.Page.ClickAsync("#LabelOptionsToggle");
- await s.Page.ClickAsync($".dropdown-menu a:has-text(\"{labelName}\")");
+ await s.Page.ClickAsync("#LabelSelectorToggle");
+ await s.Page.ClickAsync($"#LabelSelectorMenu button:has-text(\"{labelName}\")");
await s.Page.WaitForLoadStateAsync();
await TestUtils.EventuallyAsync(async () =>
{
@@ -1508,9 +1508,9 @@ namespace BTCPayServer.Tests
await s.Page.ReloadAsync();
await s.Page.WaitForLoadStateAsync();
- await s.Page.WaitForSelectorAsync("#LabelOptionsToggle");
- await s.Page.ClickAsync("#LabelOptionsToggle");
- var labelItems = await s.Page.Locator(".dropdown-menu a").AllInnerTextsAsync();
+ await s.Page.WaitForSelectorAsync("#LabelSelectorToggle");
+ await s.Page.ClickAsync("#LabelSelectorToggle");
+ var labelItems = await s.Page.Locator("#LabelSelectorMenu .label-filter-text").AllInnerTextsAsync();
var matches = labelItems.Where(t => t.Equals(labelOriginal, StringComparison.OrdinalIgnoreCase)).ToArray();
Assert.Single(matches);
Assert.Equal(labelOriginal, matches[0]);
@@ -1623,10 +1623,8 @@ namespace BTCPayServer.Tests
await s.FindAlertMessage(partialText: "User successfully updated");
await s.GoToServer(ServerNavPages.Users);
- Assert.Contains(unapproved.RegisterDetails.Email, await s.Page.GetAttributeAsync("#SearchTerm", "value"));
- Assert.Equal(1, await rows.CountAsync());
- Assert.Contains(unapproved.RegisterDetails.Email, await rows.First.TextContentAsync());
- Assert.Contains("Active", await s.Page.Locator("#UsersList tr.user-overview-row:first-child .user-status").TextContentAsync());
+ var users = new PMO.UsersPMO(s);
+ await users.AssertActive(unapproved.RegisterDetails.Email);
await s.Logout();
await s.GoToLogin();
diff --git a/BTCPayServer.Tests/WalletTests.cs b/BTCPayServer.Tests/WalletTests.cs
index a10af7f..4dd549c 100644
--- a/BTCPayServer.Tests/WalletTests.cs
+++ b/BTCPayServer.Tests/WalletTests.cs
@@ -18,6 +18,7 @@ using NBitcoin;
using NBitcoin.Payment;
using NBXplorer.Models;
using Xunit;
+using static Microsoft.Playwright.Assertions;
namespace BTCPayServer.Tests;
@@ -740,23 +741,23 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
}
await s.GoToWalletTransactions(s.WalletId);
- await s.Page.ClickAsync("#LabelOptionsToggle");
- await s.Page.Locator("#LabelDropdownMenu").WaitForAsync();
+ await s.Page.ClickAsync("#LabelSelectorToggle");
+ await s.Page.Locator("#LabelSelectorMenu").WaitForAsync();
await s.Page.Locator("#LabelSearch").WaitForAsync();
await TestUtils.EventuallyAsync(async () =>
{
- Assert.Equal(20, await s.Page.Locator("#LabelDropdownMenu .label-filter-item").CountAsync());
- Assert.True(await s.Page.Locator($"#LabelDropdownMenu .label-filter-item a:has-text('{targetLabel}')").IsVisibleAsync());
+ Assert.Equal(20, await s.Page.Locator("#LabelSelectorMenu .label-filter-item").CountAsync());
+ Assert.True(await s.Page.Locator($"#LabelSelectorMenu .label-filter-item span:has-text('{targetLabel}')").IsVisibleAsync());
var targetItem = s.Page
- .Locator("#LabelDropdownMenu .label-filter-item")
+ .Locator("#LabelSelectorMenu .label-filter-item")
.Filter(new() { Has = s.Page.Locator($".label-filter-text:text-is('{targetLabel}')") });
Assert.Equal("2", (await targetItem.Locator(".label-filter-count").InnerTextAsync()).Trim());
var singleUseLabel = labels[0];
var singleUseItem = s.Page
- .Locator("#LabelDropdownMenu .label-filter-item")
+ .Locator("#LabelSelectorMenu .label-filter-item")
.Filter(new() { Has = s.Page.Locator($".label-filter-text:text-is('{singleUseLabel}')") });
Assert.Equal("1", (await singleUseItem.Locator(".label-filter-count").InnerTextAsync()).Trim());
});
@@ -764,12 +765,12 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.Page.FillAsync("#LabelSearch", "target");
await TestUtils.EventuallyAsync(async () =>
{
- var items = await s.Page.Locator("#LabelDropdownMenu .label-filter-item").CountAsync();
+ var items = await s.Page.Locator("#LabelSelectorMenu .label-filter-item").CountAsync();
Assert.Equal(1, items);
- Assert.Equal("2", (await s.Page.Locator("#LabelDropdownMenu .label-filter-item .label-filter-count").InnerTextAsync()).Trim());
+ Assert.Equal("2", (await s.Page.Locator("#LabelSelectorMenu .label-filter-item .label-filter-count").InnerTextAsync()).Trim());
});
- await s.Page.ClickAsync($"#LabelDropdownMenu .label-filter-item a:has-text('{targetLabel}')");
+ await s.Page.ClickAsync($"#LabelSelectorMenu .label-filter-item span:has-text('{targetLabel}')");
await TestUtils.EventuallyAsync(() =>
{
Assert.Contains($"label:{targetLabel}", Uri.UnescapeDataString(s.Page.Url));
@@ -842,22 +843,17 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
Assert.True(string.IsNullOrEmpty(qsAfterSearch["SearchTerm"]));
});
- await s.Page.ClickAsync("#LabelOptionsToggle");
- await s.Page.Locator("#LabelDropdownMenu").WaitForAsync();
- await s.Page.ClickAsync($"#LabelDropdownMenu .label-filter-item a:has-text('{targetLabel}')");
- await s.Page.WaitForLoadStateAsync();
+ await s.Page.ClickAsync("#LabelSelector");
+ await s.Page.ClickAsync($"#LabelSelector .label-filter-item span:has-text('{targetLabel}')");
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Equal(targetSearchText, await s.Page.InputValueAsync("#SearchText"));
- Assert.Contains($"label:{targetLabel}", await s.Page.InputValueAsync("input[name='SearchTerm']"));
- Assert.Contains(targetLabel, await s.Page.InnerTextAsync("#LabelOptionsToggle"));
+ await Expect(s.Page.Locator("#SearchText")).ToHaveValueAsync(targetSearchText);
+ Assert.Contains($"label:{targetLabel}", await s.Page.InputValueAsync("#SearchTerm"));
+ await Expect(s.Page.Locator("#LabelSelectorToggle")).ToContainTextAsync(targetLabel);
- var urlAfterLabelFilter = new Uri(s.Page.Url);
- var qsAfterLabelFilter = HttpUtility.ParseQueryString(urlAfterLabelFilter.Query);
- Assert.Equal(targetSearchText, qsAfterLabelFilter["SearchText"]);
- Assert.Contains($"label:{targetLabel}", Uri.UnescapeDataString(qsAfterLabelFilter["SearchTerm"] ?? string.Empty));
- });
+ var urlAfterLabelFilter = new Uri(s.Page.Url);
+ var qsAfterLabelFilter = HttpUtility.ParseQueryString(urlAfterLabelFilter.Query);
+ Assert.Equal(targetSearchText, qsAfterLabelFilter["SearchText"]);
+ Assert.Contains($"label:{targetLabel}", Uri.UnescapeDataString(qsAfterLabelFilter["SearchTerm"] ?? string.Empty));
}
[Fact]
@@ -890,7 +886,6 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
var targetSearchText = transactions[0].TransactionHash.ToString()[..12];
await s.GoToWalletTransactions(s.WalletId);
- var timezoneOffset = await s.Page.EvaluateAsync<int>("() => new Date().getTimezoneOffset()");
await s.Page.FillAsync("#SearchText", targetSearchText);
await s.Page.PressAsync("#SearchText", "Enter");
await s.Page.WaitForLoadStateAsync();
@@ -905,25 +900,18 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
Assert.True(string.IsNullOrEmpty(qsAfterSearch["SearchTerm"]));
});
- await s.Page.ClickAsync("#DateOptionsToggle");
- await s.Page.ClickAsync("#DateOptionsToggle + .dropdown-menu a:text-is('Last 24 hours')");
- await s.Page.WaitForLoadStateAsync();
+ await s.Page.ClickAsync("#DateRangeSelector");
+ await s.Page.ClickAsync("#DateRangeDropdown button:has-text('This month')");
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Equal(targetSearchText, await s.Page.InputValueAsync("#SearchText"));
- Assert.Contains("24 Hours", await s.Page.InnerTextAsync("#DateOptionsToggle"));
+ await Expect(s.Page.Locator("#SearchText")).ToHaveValueAsync(targetSearchText);
+ await Expect(s.Page.Locator("#DateRangeSelector")).ToHaveTextAsync("This month");
- var hiddenSearchTerm = await s.Page.InputValueAsync("input[name='SearchTerm']");
- Assert.Contains("startdate:-1d", hiddenSearchTerm);
- Assert.DoesNotContain(targetSearchText, hiddenSearchTerm);
+ Assert.Contains("daterange:thismonth", await s.Page.InputValueAsync("#SearchTerm"));
- var urlAfterPreset = new Uri(s.Page.Url);
- var qsAfterPreset = HttpUtility.ParseQueryString(urlAfterPreset.Query);
- Assert.Equal(targetSearchText, qsAfterPreset["SearchText"]);
- Assert.Equal(timezoneOffset.ToString(CultureInfo.InvariantCulture), qsAfterPreset["timezoneOffset"] ?? qsAfterPreset["TimezoneOffset"]);
- Assert.Contains("startdate:-1d", Uri.UnescapeDataString(qsAfterPreset["SearchTerm"] ?? string.Empty));
- });
+ var urlAfterPreset = new Uri(s.Page.Url);
+ var qsAfterPreset = HttpUtility.ParseQueryString(urlAfterPreset.Query);
+ Assert.Equal(targetSearchText, qsAfterPreset["SearchText"]);
+ Assert.Contains("daterange:thismonth", Uri.UnescapeDataString(qsAfterPreset["SearchTerm"] ?? string.Empty));
}
[Fact]
@@ -967,23 +955,18 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
});
await s.GoToWalletTransactions(s.WalletId);
- await s.Page.ClickAsync("#LabelOptionsToggle");
- await s.Page.Locator("#LabelDropdownMenu").WaitForAsync();
- await s.Page.ClickAsync("#LabelDropdownMenu a:text-is('No Label')");
- await s.Page.WaitForLoadStateAsync();
+ await s.Page.ClickAsync("#LabelSelector");
+ await s.Page.ClickAsync("#LabelSelector button:text-is('No Label')");
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Contains("No Label", await s.Page.InnerTextAsync("#LabelOptionsToggle"));
- Assert.Contains("nolabel:true", await s.Page.InputValueAsync("input[name='SearchTerm']"));
+ await Expect(s.Page.Locator("#LabelSelectorToggle")).ToContainTextAsync("No Label");
+ Assert.Contains("nolabel:true", await s.Page.InputValueAsync("#SearchTerm"));
- var urlAfterNoLabelFilter = new Uri(s.Page.Url);
- var qsAfterNoLabelFilter = HttpUtility.ParseQueryString(urlAfterNoLabelFilter.Query);
- Assert.Contains("nolabel:true", Uri.UnescapeDataString(qsAfterNoLabelFilter["SearchTerm"] ?? string.Empty));
+ var urlAfterNoLabelFilter = new Uri(s.Page.Url);
+ var qsAfterNoLabelFilter = HttpUtility.ParseQueryString(urlAfterNoLabelFilter.Query);
+ Assert.Contains("nolabel:true", Uri.UnescapeDataString(qsAfterNoLabelFilter["SearchTerm"] ?? string.Empty));
- Assert.True(await s.Page.Locator($".transaction-row[data-value='{unlabeledTx.TransactionHash}']").IsVisibleAsync());
- Assert.False(await s.Page.Locator($".transaction-row[data-value='{labeledTx.TransactionHash}']").IsVisibleAsync());
- });
+ await Expect(s.Page.Locator($".transaction-row[data-value='{unlabeledTx.TransactionHash}']")).ToBeVisibleAsync();
+ await Expect(s.Page.Locator($".transaction-row[data-value='{labeledTx.TransactionHash}']")).ToBeHiddenAsync();
}
[Fact]
@@ -1048,32 +1031,28 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
});
await s.GoToWalletTransactions(s.WalletId);
- await s.Page.EvaluateAsync("() => document.querySelector(\"[data-direction-filter='out']\")?.click()");
- await s.Page.WaitForLoadStateAsync();
- await s.Page.ClickAsync("#LabelOptionsToggle");
- await s.Page.Locator("#LabelDropdownMenu").WaitForAsync();
- await s.Page.ClickAsync($"#LabelDropdownMenu .label-filter-item a:has-text('{targetLabel}')");
+ await s.Page.ClickAsync("#DirectionDropdown");
+ await s.Page.ClickAsync("#DirectionDropdown button:has-text('Outgoing')");
+ await s.Page.ClickAsync("#LabelSelector");
+ await s.Page.ClickAsync($"#LabelSelector .label-filter-item span:has-text('{targetLabel}')");
await s.Page.WaitForLoadStateAsync();
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Contains("Outgoing", await s.Page.InnerTextAsync("#DirectionOptionsToggle"));
- Assert.Contains(targetLabel, await s.Page.InnerTextAsync("#LabelOptionsToggle"));
-
- var hiddenSearchTerm = await s.Page.InputValueAsync("input[name='SearchTerm']");
- Assert.Contains("direction:out", hiddenSearchTerm);
- Assert.Contains($"label:{targetLabel}", hiddenSearchTerm);
-
- var urlAfterCombinedFilter = new Uri(s.Page.Url);
- var qsAfterCombinedFilter = HttpUtility.ParseQueryString(urlAfterCombinedFilter.Query);
- var searchTerm = Uri.UnescapeDataString(qsAfterCombinedFilter["SearchTerm"] ?? string.Empty);
- Assert.Contains("direction:out", searchTerm);
- Assert.Contains($"label:{targetLabel}", searchTerm);
-
- Assert.True(await s.Page.Locator($".transaction-row[data-value='{outgoingTx.TransactionHash}']").IsVisibleAsync());
- Assert.False(await s.Page.Locator($".transaction-row[data-value='{incomingTx.TransactionHash}']").IsVisibleAsync());
- Assert.Equal(1, await s.Page.Locator(".transaction-row").CountAsync());
- });
+ await Expect(s.Page.Locator("#DirectionOptionsToggle")).ToContainTextAsync("Outgoing");
+ await Expect(s.Page.Locator("#LabelSelectorToggle")).ToContainTextAsync(targetLabel);
+
+ var hiddenSearchTerm = await s.Page.InputValueAsync("#SearchTerm");
+ Assert.Contains("direction:out", hiddenSearchTerm);
+ Assert.Contains($"label:{targetLabel}", hiddenSearchTerm);
+
+ var urlAfterCombinedFilter = new Uri(s.Page.Url);
+ var qsAfterCombinedFilter = HttpUtility.ParseQueryString(urlAfterCombinedFilter.Query);
+ var searchTerm = Uri.UnescapeDataString(qsAfterCombinedFilter["SearchTerm"] ?? string.Empty);
+ Assert.Contains("direction:out", searchTerm);
+ Assert.Contains($"label:{targetLabel}", searchTerm);
+
+ await Expect(s.Page.Locator($".transaction-row[data-value='{outgoingTx.TransactionHash}']")).ToBeVisibleAsync();
+ await Expect(s.Page.Locator($".transaction-row[data-value='{incomingTx.TransactionHash}']")).ToBeHiddenAsync();
+ await Expect(s.Page.Locator(".transaction-row")).ToHaveCountAsync(1);
}
[Fact]
@@ -1117,16 +1096,14 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.GoToWalletTransactions(s.WalletId);
Assert.Equal(string.Empty, await s.Page.InputValueAsync("#SearchText"));
- await s.Page.EvaluateAsync("() => document.querySelector(\"[data-direction-filter='out']\")?.click()");
+ await s.Page.ClickAsync("#DirectionDropdown");
+ await s.Page.ClickAsync("#DirectionDropdown button:has-text('Outgoing')");
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Contains("direction:out", await s.Page.GetAttributeAsync("input[name='SearchTerm']", "value"));
- Assert.Contains("Outgoing", await s.Page.InnerTextAsync("#DirectionOptionsToggle"));
- Assert.Equal(string.Empty, await s.Page.InputValueAsync("#SearchText"));
- Assert.True(await s.Page.Locator(".transaction-row .amount-col .text-danger").CountAsync() > 0);
- Assert.Equal(0, await s.Page.Locator(".transaction-row .amount-col .text-success").CountAsync());
- });
+ await Expect(s.Page.Locator("#DirectionOptionsToggle")).ToContainTextAsync("Outgoing");
+ Assert.Contains("direction:out", await s.Page.InputValueAsync("#SearchTerm"));
+ await Expect(s.Page.Locator("#SearchText")).ToHaveValueAsync(string.Empty);
+ await Expect(s.Page.Locator(".transaction-row .amount-col .text-danger").First).ToBeVisibleAsync();
+ await Expect(s.Page.Locator(".transaction-row .amount-col .text-success")).ToHaveCountAsync(0);
}
[Fact]
@@ -1179,39 +1156,52 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
});
await s.GoToWalletTransactions(s.WalletId);
+
+ await s.Page.ClickAsync("#DateRangeSelector");
+ var browserTimeZone = await s.Page.EvaluateAsync<string>("() => Intl.DateTimeFormat().resolvedOptions().timeZone");
+ await Expect(s.Page.Locator("#DateRangeTimeZone")).ToHaveValueAsync(browserTimeZone + " (Default)");
+
+ const string selectedTimeZone = "America/New_York";
+ await s.Page.FillAsync("#DateRangeTimeZone", selectedTimeZone);
+ await s.Page.PressAsync("#DateRangeTimeZone", "Enter");
+ await s.Page.WaitForLoadStateAsync();
+ await s.Page.ClickAsync("#DateRangeSelector");
+ await Expect(s.Page.Locator("#DateRangeTimeZone")).ToHaveValueAsync(selectedTimeZone);
+
await s.Page.FillAsync("#SearchText", targetLabel);
await s.Page.PressAsync("#SearchText", "Enter");
- await s.Page.WaitForLoadStateAsync();
- await s.Page.EvaluateAsync("() => document.querySelector(\"[data-direction-filter='out']\")?.click()");
- await s.Page.WaitForLoadStateAsync();
+ await s.Page.ClickAsync("#DirectionDropdown");
+ await s.Page.ClickAsync("#DirectionDropdown button:has-text('Outgoing')");
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Equal(targetLabel, await s.Page.InputValueAsync("#SearchText"));
- Assert.Contains("direction:out", await s.Page.InputValueAsync("input[name='SearchTerm']"));
- Assert.Contains("Outgoing", await s.Page.InnerTextAsync("#DirectionOptionsToggle"));
- Assert.Equal(1, await s.Page.Locator(".transaction-row").CountAsync());
- Assert.Equal(1, await s.Page.Locator("#clearAllFiltersBtn").CountAsync());
- });
+ await Expect(s.Page.Locator("#SearchText")).ToHaveValueAsync(targetLabel);
+ var searchTermWithFilters = await s.Page.InputValueAsync("#SearchTerm");
+ Assert.Contains("direction:out", searchTermWithFilters);
+ Assert.Contains($"timezone:{selectedTimeZone}", searchTermWithFilters);
+ await Expect(s.Page.Locator("#DirectionOptionsToggle")).ToContainTextAsync("Outgoing");
+ await Expect(s.Page.Locator(".transaction-row")).ToHaveCountAsync(1);
+ await Expect(s.Page.Locator("#clearAllFiltersBtn")).ToHaveCountAsync(1);
await s.Page.ClickAsync("#clearAllFiltersBtn");
await s.Page.WaitForLoadStateAsync();
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Equal(string.Empty, await s.Page.InputValueAsync("#SearchText"));
- Assert.Equal(string.Empty, await s.Page.InputValueAsync("input[name='SearchTerm']"));
- Assert.Contains("All Directions", await s.Page.InnerTextAsync("#DirectionOptionsToggle"));
- Assert.Equal(0, await s.Page.Locator("#clearAllFiltersBtn").CountAsync());
- Assert.True(await s.Page.Locator(".transaction-row").CountAsync() >= 2);
- Assert.True(await s.Page.Locator(".transaction-row .amount-col .text-danger").CountAsync() > 0);
- Assert.True(await s.Page.Locator(".transaction-row .amount-col .text-success").CountAsync() > 0);
-
- var urlAfterClearAll = new Uri(s.Page.Url);
- var qsAfterClearAll = HttpUtility.ParseQueryString(urlAfterClearAll.Query);
- Assert.True(string.IsNullOrEmpty(qsAfterClearAll["SearchText"]));
- Assert.True(string.IsNullOrEmpty(qsAfterClearAll["SearchTerm"]));
- });
+ await Expect(s.Page.Locator("#SearchText")).ToHaveValueAsync(string.Empty);
+ await Expect(s.Page.Locator("#SearchTerm")).ToHaveValueAsync($"timezone:{selectedTimeZone}");
+ await s.Page.ClickAsync("#DateRangeSelector");
+ await Expect(s.Page.Locator("#DateRangeTimeZone")).ToHaveValueAsync(selectedTimeZone);
+ await Expect(s.Page.Locator("#DirectionOptionsToggle")).ToContainTextAsync("All Directions");
+ await Expect(s.Page.Locator("#clearAllFiltersBtn")).ToHaveCountAsync(0);
+ Assert.True(await s.Page.Locator(".transaction-row").CountAsync() >= 2);
+ await Expect(s.Page.Locator(".transaction-row .amount-col .text-danger").First).ToBeVisibleAsync();
+ await Expect(s.Page.Locator(".transaction-row .amount-col .text-success").First).ToBeVisibleAsync();
+
+ var urlAfterClearAll = new Uri(s.Page.Url);
+ var qsAfterClearAll = HttpUtility.ParseQueryString(urlAfterClearAll.Query);
+ Assert.True(string.IsNullOrEmpty(qsAfterClearAll["SearchText"]));
+ Assert.True(string.IsNullOrEmpty(qsAfterClearAll["SearchTerm"]));
+
+ await s.GoToInvoices(s.StoreId);
+ await s.Page.ClickAsync("#DateRangeSelector");
+ await Expect(s.Page.Locator("#DateRangeTimeZone")).ToHaveValueAsync(selectedTimeZone);
}
[Fact]
@@ -1278,102 +1268,34 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
});
await s.GoToWalletTransactions(s.WalletId);
- var timezoneOffset = await s.Page.EvaluateAsync<int>("() => new Date().getTimezoneOffset()");
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Equal(timezoneOffset.ToString(CultureInfo.InvariantCulture), await s.Page.InputValueAsync("#TimezoneOffset"));
- });
-
await s.Page.FillAsync("#SearchText", targetSearchText);
await s.Page.PressAsync("#SearchText", "Enter");
await s.Page.WaitForLoadStateAsync();
- await s.Page.EvaluateAsync("() => document.querySelector(\"[data-direction-filter='out']\")?.click()");
- await s.Page.WaitForLoadStateAsync();
+ await s.Page.ClickAsync("#DirectionDropdown");
+ await s.Page.ClickAsync("#DirectionDropdown button:has-text('Outgoing')");
- await s.Page.ClickAsync("#LabelOptionsToggle");
- await s.Page.Locator("#LabelDropdownMenu").WaitForAsync();
- await s.Page.ClickAsync($"#LabelDropdownMenu .label-filter-item a:has-text('{targetLabel}')");
- await s.Page.WaitForLoadStateAsync();
+ await s.Page.ClickAsync("#LabelSelector");
+ await s.Page.ClickAsync($"#LabelSelector .label-filter-item span:has-text('{targetLabel}')");
- await s.Page.ClickAsync("#DateOptionsToggle");
+ await s.Page.ClickAsync("#DateRangeSelector");
await s.Page.ClickAsync("[data-bs-target='#customRangeModal']");
await s.Page.EvaluateAsync("() => { document.getElementById('dtpStartDate').value = '2026-03-01T12:34:56'; }");
await s.Page.ClickAsync("#btnCustomRangeDate");
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Contains(targetSearchText, await s.Page.InputValueAsync("#SearchText"));
+ await Expect(s.Page.Locator("#SearchText")).ToHaveValueAsync(targetSearchText);
- var hiddenSearchTerm = await s.Page.InputValueAsync("input[name='SearchTerm']");
- Assert.Contains("direction:out", hiddenSearchTerm);
- Assert.Contains($"label:{targetLabel}", hiddenSearchTerm);
- Assert.Contains("startdate:2026-03-01T12:34:56", hiddenSearchTerm);
+ var hiddenSearchTerm = await s.Page.InputValueAsync("#SearchTerm");
+ Assert.Contains("direction:out", hiddenSearchTerm);
+ Assert.Contains($"label:{targetLabel}", hiddenSearchTerm);
+ Assert.Contains("startdate:2026-03-01T12:34:56", hiddenSearchTerm);
- foreach (var exportSelector in new[] { "#ExportCSV", "#ExportJSON", "#ExportBIP329" })
- {
- var href = await s.Page.GetAttributeAsync(exportSelector, "href");
- Assert.False(string.IsNullOrEmpty(href));
-
- var exportUri = new Uri(href!, UriKind.RelativeOrAbsolute);
- if (!exportUri.IsAbsoluteUri)
- exportUri = new Uri(new Uri(s.Page.Url), exportUri);
-
- var qs = HttpUtility.ParseQueryString(exportUri.Query);
- Assert.Equal(targetSearchText, qs["searchText"] ?? qs["SearchText"]);
- Assert.Equal(timezoneOffset.ToString(CultureInfo.InvariantCulture), qs["timezoneOffset"] ?? qs["TimezoneOffset"]);
-
- var exportSearchTerm = Uri.UnescapeDataString(qs["searchTerm"] ?? qs["SearchTerm"] ?? string.Empty);
- Assert.Contains("direction:out", exportSearchTerm);
- Assert.Contains($"label:{targetLabel}", exportSearchTerm);
- Assert.Contains("startdate:2026-03-01T12:34:56", exportSearchTerm);
- Assert.DoesNotContain(otherLabel, exportSearchTerm);
- }
- });
- }
-
- [Fact]
- [Trait("Playwright", "Playwright-2")]
- public async Task CanApplyWalletTransactionCustomDateFilterUsingClientTimezoneOffset()
- {
- await using var s = CreatePlaywrightTester();
- await s.StartAsync();
- await s.Server.ExplorerNode.GenerateAsync(1);
- await s.RegisterNewUser(true);
- await s.CreateNewStore();
- await s.GenerateWallet(isHotWallet: true);
-
- await s.GoToWallet(s.WalletId, WalletsNavPages.Receive);
- var addressStr = await s.Page.GetAttributeAsync("#Address", "data-text");
- var address = BitcoinAddress.Create(addressStr!, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
- await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.5m));
- await s.Server.ExplorerNode.GenerateAsync(1);
-
- var client = await s.AsTestAccount().CreateClient();
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.True((await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Any());
- });
-
- await s.GoToWalletTransactions(s.WalletId);
- var timezoneOffset = await s.Page.EvaluateAsync<int>("() => new Date().getTimezoneOffset()");
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Equal(timezoneOffset.ToString(CultureInfo.InvariantCulture), await s.Page.InputValueAsync("#TimezoneOffset"));
- });
-
- await s.Page.ClickAsync("#DateOptionsToggle");
- await s.Page.ClickAsync("[data-bs-target='#customRangeModal']");
- await s.Page.EvaluateAsync("() => { document.getElementById('dtpStartDate').value = '2026-03-01T12:34:56'; }");
- await s.Page.ClickAsync("#btnCustomRangeDate");
-
- await TestUtils.EventuallyAsync(async () =>
- {
- Assert.Contains($"timezoneOffset={timezoneOffset}", s.Page.Url);
- Assert.Contains("startdate:2026-03-01T12:34:56", Uri.UnescapeDataString(s.Page.Url));
- Assert.Contains("Custom", await s.Page.InnerTextAsync("#DateOptionsToggle"));
- Assert.Contains($"timezoneOffset={timezoneOffset}", await s.Page.GetAttributeAsync("#ExportCSV", "href"));
- });
+ await Expect(s.Page.Locator("#Export input[name='searchText']")).ToHaveValueAsync(targetSearchText);
+ var exportSearchTerm = await s.Page.InputValueAsync("#Export input[name='searchTerm']");
+ Assert.Contains("direction:out", exportSearchTerm);
+ Assert.Contains($"label:{targetLabel}", exportSearchTerm);
+ Assert.Contains("startdate:2026-03-01T12:34:56", exportSearchTerm);
+ Assert.DoesNotContain(otherLabel, exportSearchTerm);
}
private async Task CreateInvoices(PlaywrightTester tester)
diff --git a/BTCPayServer/Components/ClearAllFilters/ClearAllFilters.cs b/BTCPayServer/Components/ClearAllFilters/ClearAllFilters.cs
new file mode 100644
index 0000000..554bdee
--- /dev/null
+++ b/BTCPayServer/Components/ClearAllFilters/ClearAllFilters.cs
@@ -0,0 +1,11 @@
+#nullable enable
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Components.ClearAllFilters;
+
+[ViewComponent]
+public class ClearAllFilters : ViewComponent
+{
+ public IViewComponentResult Invoke(SearchString? search)
+ => View(search);
+}
diff --git a/BTCPayServer/Components/ClearAllFilters/Default.cshtml b/BTCPayServer/Components/ClearAllFilters/Default.cshtml
new file mode 100644
index 0000000..3133ec8
--- /dev/null
+++ b/BTCPayServer/Components/ClearAllFilters/Default.cshtml
@@ -0,0 +1,16 @@
+@model SearchString
+@functions
+{
+ public static bool IsEmpty(SearchString searchString) =>
+ string.IsNullOrWhiteSpace(searchString.TextSearch) &&
+ searchString.Filters.All(f => f.Key.Equals("timezone", StringComparison.OrdinalIgnoreCase));
+}
+@if (Model is not null && !IsEmpty(Model)) {
+ <button id="clearAllFiltersBtn"
+ type="submit" name="FilterCommand" value="reset"
+ class="btn btn-secondary"
+ style="min-width: 7rem;"
+ title="@StringLocalizer["Clear all filters"]">
+ <span class="align-middle" text-translate="true">Clear All</span>
+ </button>
+}
diff --git a/BTCPayServer/Components/DateColumn/DateColumn.cs b/BTCPayServer/Components/DateColumn/DateColumn.cs
new file mode 100644
index 0000000..7ac1353
--- /dev/null
+++ b/BTCPayServer/Components/DateColumn/DateColumn.cs
@@ -0,0 +1,18 @@
+#nullable enable
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+
+namespace BTCPayServer.Components.DateColumn;
+public class DateColumn(IStringLocalizer stringLocalizer) : ViewComponent
+{
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+
+ public string? ColumnName { get; set; }
+ public IViewComponentResult Invoke(string? columnName = null)
+ {
+ if (columnName is null)
+ columnName = StringLocalizer["Date"];
+ ColumnName = columnName;
+ return View(this);
+ }
+}
diff --git a/BTCPayServer/Components/DateColumn/Default.cshtml b/BTCPayServer/Components/DateColumn/Default.cshtml
new file mode 100644
index 0000000..db50fdf
--- /dev/null
+++ b/BTCPayServer/Components/DateColumn/Default.cshtml
@@ -0,0 +1,11 @@
+@model BTCPayServer.Components.DateColumn.DateColumn
+
+<th class="date-col">
+ <div>
+ <span>@Model.ColumnName</span>
+ <button type="button" class="btn btn-link p-0 ms-2 switch-time-format only-for-js" title="@StringLocalizer["Switch date format"]">
+ <vc:icon symbol="time" />
+ </button>
+ <span>(<span timezone></span>)</span>
+ </div>
+</th>
diff --git a/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs b/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs
new file mode 100644
index 0000000..defff74
--- /dev/null
+++ b/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs
@@ -0,0 +1,61 @@
+#nullable enable
+using System;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Components.DateRangeSelector;
+
+public class DateRangeSelector : ViewComponent
+{
+ public IViewComponentResult Invoke(
+ SearchString search,
+ string? customRangeTitle = null)
+ => View(new DateRangeSelectorModel
+ {
+ Search = search ?? throw new ArgumentNullException(nameof(search)),
+ CustomRangeTitle = customRangeTitle ?? "Filter by Custom Range",
+ Url = Url
+ });
+}
+
+public class DateRangeSelectorModel
+{
+ private const string SearchTermRouteKey = "searchTerm";
+
+ public required SearchString Search { get; init; }
+ public required string CustomRangeTitle { get; init; }
+ public required IUrlHelper Url { get; init; }
+
+
+ public DateRangeSelectorModel()
+ {
+ var tz = TimeZoneInfo.GetSystemTimeZones()
+ .Select(t => (Id: t.Id, Name: t.DisplayName))
+ .ToList();
+ tz.Insert(0, default);
+ TimeZones = tz.ToArray();
+ }
+
+ public (string Id, string Name)[] TimeZones { get; }
+
+ public bool HasDateFilter => Search.HasArrayFilter("startdate") || Search.HasArrayFilter("enddate") || Search.HasArrayFilter("daterange");
+
+ public bool HasCustomDateFilter =>
+ HasDateFilter &&
+ (IsDate("startdate") && (!Search.HasArrayFilter("enddate") || IsDate("enddate")));
+
+ private bool IsDate(string val) => Search.GetFilterDate(val, TimeZoneInfo.Utc) is not null;
+
+ public bool HasDateRange(string value) => Search.HasArrayFilter("daterange", value);
+
+ public static string RemoveDatePreset(string? search)
+ {
+ var s = new SearchString(search);
+ s.Filters.Remove("daterange");
+ s.Filters.Remove("startdate");
+ s.Filters.Remove("enddate");
+ return s.ToString();
+ }
+}
diff --git a/BTCPayServer/Components/DateRangeSelector/Default.cshtml b/BTCPayServer/Components/DateRangeSelector/Default.cshtml
new file mode 100644
index 0000000..7b1c2fd
--- /dev/null
+++ b/BTCPayServer/Components/DateRangeSelector/Default.cshtml
@@ -0,0 +1,176 @@
+@using BTCPayServer.Components.DateRangeSelector
+@model BTCPayServer.Components.DateRangeSelector.DateRangeSelectorModel
+
+@{
+ List<(String DateRange, String Label)> filters = new()
+ {
+ ("today", StringLocalizer["Today"].Value),
+ ("yesterday", StringLocalizer["Yesterday"].Value),
+ ("thisweek", StringLocalizer["This week"].Value),
+ ("lastweek", StringLocalizer["Last week"].Value),
+ ("thismonth", StringLocalizer["This month"].Value),
+ ("lastmonth", StringLocalizer["Last month"].Value),
+ ("thisquarter", StringLocalizer["This quarter"].Value),
+ ("lastquarter", StringLocalizer["Last quarter"].Value),
+ ("thisyear", StringLocalizer["This year"].Value),
+ ("lastyear", StringLocalizer["Last year"].Value)
+ };
+
+}
+
+<div class="dropdown" id="DateRangeDropdown">
+ <button id="DateRangeSelector" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
+ @if (Model.HasDateFilter)
+ {
+ var filter = filters.FirstOrDefault(f => Model.HasDateRange(f.DateRange));
+ if (filter.DateRange is not null)
+ {
+ <span>@filter.Label</span>
+ }
+ else
+ {
+ <span text-translate="true">Custom range</span>
+ }
+ }
+ else
+ {
+ <span text-translate="true">All Time</span>
+ }
+ </button>
+ <div class="dropdown-menu" aria-labelledby="DateRangeSelector">
+ <div class="px-3 py-2" style="min-width: 18rem;">
+ <label for="DateRangeTimeZone" class="form-label small text-muted mb-1" text-translate="true">Query timezone</label>
+ <!-- The input will get filled automatically by the browser (thanks to the timezone attribute) if the search string doesn't have a timezone -->
+ <input id="DateRangeTimeZone" name="TimeZone" class="form-control form-control-sm" timezone value="@Model.Search.GetExplicitTimeZone()" list="DateRangeTimeZoneOptions" autocomplete="off" />
+ <datalist id="DateRangeTimeZoneOptions">
+ @foreach (var timeZone in Model.TimeZones)
+ {
+ if (timeZone.Id is null)
+ {
+ <option id="DateRangeTimeZone-DefaultOption" value="Default"><span text-translate="true">Detect from browser</span> <span>(<span browser-timezone></span>)</span></option>
+ }
+ else
+ {
+ <option value="@timeZone.Id">@timeZone.Name</option>
+ }
+ }
+ </datalist>
+ </div>
+ <hr class="dropdown-divider" />
+ <button type="submit" name="FilterCommand" value="alltime" class="dropdown-item @(!Model.HasDateFilter ? "custom-active" : string.Empty)" text-translate="true">All Time</button>
+ <hr class="dropdown-divider" />
+ @foreach (var filter in filters)
+ {
+ <button type="submit" name="FilterCommand" value="set-daterange:@filter.DateRange" class="dropdown-item @(Model.HasDateRange(@filter.DateRange) ? "custom-active" : string.Empty)" text-translate="true">@filter.Label</button>
+ }
+ <button type="button" class="dropdown-item @(Model.HasCustomDateFilter ? "custom-active" : string.Empty)" data-bs-toggle="modal" data-bs-target="#customRangeModal" text-translate="true">Custom range</button>
+ </div>
+</div>
+
+<div class="modal fade" id="customRangeModal" tabindex="-1" role="dialog" aria-labelledby="customRangeModalTitle" aria-hidden="true" data-bs-backdrop="static">
+ <div class="modal-dialog modal-dialog-centered" role="document" style="max-width: 550px;">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="customRangeModalTitle">@Model.CustomRangeTitle</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ <div class="modal-body">
+ <div class="form-group row">
+ <label for="dtpStartDate" class="col-sm-3 col-form-label" text-translate="true">Start Date</label>
+ <div class="col-sm-9">
+ <div class="input-group">
+ <input id="dtpStartDate" class="form-control flatdtpicker" type="datetime-local"
+ data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "defaultHour": 0 }'
+ placeholder="@StringLocalizer["Start Date"]" />
+ <button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="dtpEndDate" class="col-sm-3 col-form-label" text-translate="true">End Date</label>
+ <div class="col-sm-9">
+ <div class="input-group">
+ <input id="dtpEndDate" class="form-control flatdtpicker" type="datetime-local"
+ data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "defaultHour": 0 }'
+ placeholder="@StringLocalizer["End Date"]" />
+ <button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button id="btnCustomRangeDate" type="button" class="btn btn-primary" text-translate="true">Filter</button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<script>
+ (function() {
+ var dateRangeTimeZone = document.getElementById('DateRangeTimeZone');
+ var defaultOption = document.getElementById('DateRangeTimeZone-DefaultOption');
+ var defaultValue = Intl.DateTimeFormat().resolvedOptions().timeZone + ' (' + @Safe.Json(StringLocalizer["Default"].Value) + ')';
+ defaultOption.value = defaultValue;
+
+ var expicitTimeZone = @Safe.Json(Model.Search.GetExplicitTimeZone());
+ if (expicitTimeZone) {
+ dateRangeTimeZone.value = expicitTimeZone;
+ }
+ else {
+ dateRangeTimeZone.value = window.defaultDateTimeFormat.timeZone ?? defaultValue;
+ }
+
+ function updatePreferredTimeZone() {
+ if (dateRangeTimeZone.value === defaultValue || dateRangeTimeZone.value === "") {
+ dateRangeTimeZone.value = defaultValue;
+ window.clearPreferredTimeZone();
+ } else {
+ window.setPreferredTimeZone(dateRangeTimeZone.value);
+ }
+ }
+
+ dateRangeTimeZone.addEventListener('change', function () {
+ updatePreferredTimeZone();
+ this.closest('form').submit();
+ });
+
+ dateRangeTimeZone.addEventListener('keydown', function (event) {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ updatePreferredTimeZone();
+ this.closest('form').submit();
+ }
+ });
+
+ document.getElementById('btnCustomRangeDate').addEventListener('click', function () {
+ const dtpStartDate = document.getElementById("dtpStartDate").value;
+ const dtpEndDate = document.getElementById("dtpEndDate").value;
+
+ if (!dtpStartDate && !dtpEndDate) {
+ document.getElementById("dtpStartDate").nextElementSibling.focus();
+ return;
+ }
+
+ const dateFilters = [];
+ if (dtpStartDate) {
+ dateFilters.push(`startdate:${dtpStartDate}`);
+ }
+ if (dtpEndDate) {
+ dateFilters.push(`enddate:${dtpEndDate}`);
+ }
+
+ const cleanedSearchTerm = @Safe.Json(DateRangeSelectorModel.RemoveDatePreset(Model.Search.ToString(SearchStringFormat.OnlyUIFilters)));
+ const combinedSearchTerm = [cleanedSearchTerm, dateFilters.join(',')].filter(Boolean).join(',');
+
+ const searchTermInput = document.getElementById("SearchTerm");
+ searchTermInput.value = combinedSearchTerm;
+ searchTermInput.closest('form').submit();
+ });
+ })();
+</script>
diff --git a/BTCPayServer/Components/DeclareDateFormatterOptions/DeclareDateFormatterOptions.cs b/BTCPayServer/Components/DeclareDateFormatterOptions/DeclareDateFormatterOptions.cs
new file mode 100644
index 0000000..a5efca9
--- /dev/null
+++ b/BTCPayServer/Components/DeclareDateFormatterOptions/DeclareDateFormatterOptions.cs
@@ -0,0 +1,10 @@
+#nullable enable
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Components.DateFormatterOptions;
+
+public class DeclareDateFormatterOptions : ViewComponent
+{
+ public IViewComponentResult Invoke()
+ => View();
+}
diff --git a/BTCPayServer/Components/DeclareDateFormatterOptions/Default.cshtml b/BTCPayServer/Components/DeclareDateFormatterOptions/Default.cshtml
new file mode 100644
index 0000000..77976ee
--- /dev/null
+++ b/BTCPayServer/Components/DeclareDateFormatterOptions/Default.cshtml
@@ -0,0 +1,38 @@
+<script src="~/main/datetime.js" asp-append-version="true"></script>
+<script>
+ window.defaultDateTimeFormat = {
+ "dateStyle": "short",
+ "timeStyle": "short",
+ "timeZone" : @Safe.Json(ViewData.GetPageTimeZone())
+ };
+
+ // Order of preference: browser < page time zone < user-preferred time zone
+ (function()
+ {
+ const storeId = @Safe.Json(Context.GetStoreDataOrNull()?.Id);
+ if (!storeId) {
+ return;
+ }
+ const timeZoneStorageKey = `${storeId}-preferredTimeZone`;
+ window.setPreferredTimeZone = (timeZone) => {
+ localStorage.setItem(timeZoneStorageKey, timeZone);
+ };
+ window.getPreferredTimeZone = () => {
+ return localStorage.getItem(timeZoneStorageKey);
+ };
+ window.clearPreferredTimeZone = () => {
+ localStorage.removeItem(timeZoneStorageKey);
+ };
+
+ var tz = window.getPreferredTimeZone();
+ if (tz)
+ defaultDateTimeFormat.timeZone = tz;
+ })();
+
+ // Make sure the time zone is valid, else things will break later.
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: window.defaultDateTimeFormat.timeZone });
+ } catch {
+ window.defaultDateTimeFormat.timeZone = null;
+ }
+</script>
diff --git a/BTCPayServer/Components/LabelSelector/Default.cshtml b/BTCPayServer/Components/LabelSelector/Default.cshtml
new file mode 100644
index 0000000..4c8edcc
--- /dev/null
+++ b/BTCPayServer/Components/LabelSelector/Default.cshtml
@@ -0,0 +1,146 @@
+@using BTCPayServer.Components.LabelSelector
+@model LabelSelector.LabelSelectorModel
+
+@if (Model.Labels.Any())
+{
+ <style>
+ #LabelSelectorMenu {
+ max-height: 480px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ }
+
+ #LabelSelectorMenu #LabelSearchContainer .input-group:focus-within {
+ border-color: var(--btcpay-form-border-focus) !important;
+ }
+
+ #LabelSelectorMenu #LabelSearchContainer {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: var(--btcpay-dropdown-bg);
+ border-bottom: var(--bs-border-width, 1px) solid var(--bs-border-color, rgba(0,0,0,.175));
+ }
+
+ #LabelSelectorMenu .label-filter-link {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ }
+
+ #LabelSelectorMenu .label-filter-count {
+ margin-left: auto;
+ text-align: right;
+ opacity: 0.75;
+ }
+ </style>
+
+ <div class="dropdown" id="LabelSelector">
+ <button id="LabelSelectorToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+ @if (Model.LabelFilterCount > 1)
+ {
+ <span>@StringLocalizer["{0} Labels", Model.LabelFilterCount]</span>
+ }
+ else if (Model.ActiveLabels.Length == 1)
+ {
+ <span text-translate="true">Label:</span>
+ <span>@Model.ActiveLabels[0]</span>
+ }
+ else if (Model.HasNoLabelFilter)
+ {
+ <span text-translate="true">No Label</span>
+ }
+ else
+ {
+ <span text-translate="true">All Labels</span>
+ }
+ </button>
+ <ul class="dropdown-menu mt-1 py-0" id="LabelSelectorMenu" aria-labelledby="LabelSelectorToggle" style="min-width:280px">
+ <li class="px-2 pt-2 pb-2@(Model.Labels.Count > LabelSelector.MaxVisibleLabels ? string.Empty : " d-none")" id="LabelSearchContainer">
+ <div class="input-group border rounded">
+ <span class="input-group-text border-0 text-muted px-2" style="background:transparent">
+ <vc:icon symbol="actions-search" />
+ </span>
+ <input type="text" class="form-control border-0 shadow-none ps-0 bg-transparent" id="LabelSearch"
+ placeholder="@StringLocalizer["Search labels…"]" autocomplete="off" aria-label="@StringLocalizer["Search labels…"]" />
+ </div>
+ </li>
+ <li>
+ <button type="submit" name="FilterCommand" value="alllabels" class="dropdown-item @(Model.LabelFilterCount == 0 ? "custom-active" : string.Empty)" text-translate="true">All Labels</button>
+ </li>
+ @if (Model.AllowNoLabelFilter)
+ {
+ <li>
+ <button type="submit" name="FilterCommand" value="nolabel" class="dropdown-item @(Model.HasNoLabelFilter ? "custom-active" : string.Empty)" text-translate="true">No Label</button>
+ </li>
+ }
+ <li><hr class="dropdown-divider"></li>
+ <li id="LabelFilterResultsAnchor" class="d-none"></li>
+ @foreach (var label in Model.InitialLabels)
+ {
+ <li class="label-filter-item">
+ <button type="submit" name="FilterCommand" value="addlabel:@label.Text" class="dropdown-item transaction-label-text label-filter-link@(Model.ActiveLabels.Any(value => value.Equals(label.Text, StringComparison.OrdinalIgnoreCase)) ? " active" : string.Empty)" style="--btcpay-dropdown-link-active-bg:@label.Color;--btcpay-dropdown-link-active-color:@label.TextColor;">
+ <span class="label-filter-text">@label.Text</span>
+ <small class="label-filter-count@(label.UsageCount > 0 ? string.Empty : " d-none")">@label.UsageCount</small>
+ </button>
+ </li>
+ }
+ </ul>
+ <template id="label-filter-item-template">
+ <li class="label-filter-item">
+ <button type="submit" name="FilterCommand" class="dropdown-item transaction-label-text label-filter-link">
+ <span class="label-filter-text"></span>
+ <small class="label-filter-count d-none"></small>
+ </button>
+ </li>
+ </template>
+ </div>
+
+ <script>
+ (() => {
+ const $labelSearch = document.getElementById('LabelSearch');
+ if (!$labelSearch) return;
+
+ const allLabels = @Safe.Json(Model.Labels.Select(label => new { text = label.Text, color = label.Color, textColor = label.TextColor, usageCount = label.UsageCount }));
+ const initialLabels = @Safe.Json(Model.InitialLabels.Select(label => new { text = label.Text, color = label.Color, textColor = label.TextColor, usageCount = label.UsageCount }));
+ const activeLabels = @Safe.Json(Model.ActiveLabels);
+ const $menu = document.getElementById('LabelSelectorMenu');
+ const $anchor = document.getElementById('LabelFilterResultsAnchor');
+
+ function renderLabelItems(labels) {
+ $menu.querySelectorAll('.label-filter-item').forEach(el => el.remove());
+ const fragment = document.createDocumentFragment();
+ const $template = document.getElementById('label-filter-item-template');
+
+ labels.forEach(label => {
+ const labelFilterItem = $template.content.cloneNode(true);
+ const button = labelFilterItem.querySelector('button');
+ const labelText = button.querySelector('.label-filter-text');
+ const labelCount = button.querySelector('.label-filter-count');
+ button.value = 'addlabel:' + label.text;
+ if (activeLabels.some(value => value.toLowerCase() === label.text.toLowerCase())) button.classList.add('active');
+ button.style.setProperty('--btcpay-dropdown-link-active-bg', label.color);
+ button.style.setProperty('--btcpay-dropdown-link-active-color', label.textColor);
+ labelText.textContent = label.text;
+ if (label.usageCount > 0) {
+ labelCount.classList.remove('d-none');
+ labelCount.textContent = label.usageCount;
+ } else {
+ labelCount.classList.add('d-none');
+ labelCount.textContent = '';
+ }
+ fragment.appendChild(labelFilterItem);
+ });
+
+ $anchor.after(fragment);
+ }
+
+ $labelSearch.addEventListener('click', e => e.stopPropagation());
+ $labelSearch.addEventListener('input', () => {
+ const query = $labelSearch.value.toLowerCase().trim();
+ renderLabelItems(query ? allLabels.filter(label => label.text.toLowerCase().includes(query)) : initialLabels);
+ });
+ })();
+ </script>
+}
diff --git a/BTCPayServer/Components/LabelSelector/LabelSelector.cs b/BTCPayServer/Components/LabelSelector/LabelSelector.cs
new file mode 100644
index 0000000..21c49b7
--- /dev/null
+++ b/BTCPayServer/Components/LabelSelector/LabelSelector.cs
@@ -0,0 +1,89 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using BTCPayServer.Plugins.Wallets.Views.ViewModels;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Components.LabelSelector;
+
+[ViewComponent]
+public class LabelSelector : ViewComponent
+{
+ public const int MaxVisibleLabels = 20;
+
+
+ public IViewComponentResult Invoke(
+ SearchString search,
+ bool allowNoLabelFilter = false,
+ IEnumerable<LabelSelectorItemViewModel>? labels = null)
+ {
+ var allLabels = (labels ?? new List<LabelSelectorItemViewModel>())
+ .OrderBy(label => label.Text, StringComparer.OrdinalIgnoreCase)
+ .Select(label => new LabelSelectorItemViewModel
+ {
+ Text = label.Text,
+ Color = label.Color,
+ TextColor = label.TextColor,
+ UsageCount = label.UsageCount
+ })
+ .ToList();
+ var popular =
+ allLabels
+ .OrderByDescending(c => c.UsageCount)
+ .ThenBy(c => c.Text, StringComparer.OrdinalIgnoreCase)
+ .Take(MaxVisibleLabels)
+ .OrderBy(c => c.Text, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ return View(new LabelSelectorModel
+ {
+ Search = search,
+ Labels = allLabels,
+ InitialLabels = popular,
+ AllowNoLabelFilter = allowNoLabelFilter
+ });
+ }
+ public class LabelSelectorModel
+ {
+ public required SearchString Search { get; init; }
+ public required List<LabelSelectorItemViewModel> Labels { get; init; }
+ public required List<LabelSelectorItemViewModel> InitialLabels { get; init; }
+ public string[] ActiveLabels => Search.GetFilterArray("label") ?? [];
+ public bool HasNoLabelFilter => Search.GetFilterBool("nolabel") is true;
+ public int LabelFilterCount => ActiveLabels.Length + (HasNoLabelFilter ? 1 : 0);
+ public bool AllowNoLabelFilter { get; init; }
+ }
+
+ public static void RunFilterCommand(SearchString search, string filterCommand)
+ {
+ if (filterCommand is "alllabels")
+ {
+ search.Filters.Remove("label");
+ search.Filters.Remove("nolabel");
+ }
+ else if (filterCommand is "nolabel")
+ {
+ search.SetFilter("nolabel", "true");
+ search.Filters.Remove("label");
+ }
+ else if (filterCommand.StartsWith("addlabel:"))
+ {
+ search.Filters.Remove("nolabel");
+ search.SetFilter("label", filterCommand.Substring("addlabel:".Length), toggle: true, multi: true);
+ }
+ }
+
+ public static void AddUIFilters(SearchString search)
+ {
+ foreach (var filter in new[]{"label", "nolabel"})
+ search.UIFilterTypes.Add(filter);
+ }
+}
+
+public class LabelSelectorItemViewModel
+{
+ public required string Text { get; init; }
+ public required string Color { get; init; }
+ public required string TextColor { get; init; }
+ public long UsageCount { get; init; }
+}
diff --git a/BTCPayServer/Components/Pager/Default.cshtml b/BTCPayServer/Components/Pager/Default.cshtml
index e88448f..2899e89 100644
--- a/BTCPayServer/Components/Pager/Default.cshtml
+++ b/BTCPayServer/Components/Pager/Default.cshtml
@@ -77,10 +77,14 @@
var query = new Dictionary<string, object>
{
{ "searchTerm", Model.SearchTerm },
- { "timezoneOffset", Model.TimezoneOffset },
{ "skip", skip },
{ "count", count }
};
+ if (Model.TimeZone is not null)
+ {
+ if (TimeZones.TryGet(Model.TimeZone) is {} tz)
+ query.Add("timeZone", tz.Id);
+ }
if (Model.PaginationQuery != null)
{
@@ -93,7 +97,7 @@
return ReplaceQueryParameters(query);
}
-
+
string ReplaceQueryParameters(Dictionary<string, object> query)
{
var uri = new Uri(ViewContext.HttpContext.Request.GetCurrentUrlWithQueryString());
diff --git a/BTCPayServer/Components/SearchStringInput/Default.cshtml b/BTCPayServer/Components/SearchStringInput/Default.cshtml
new file mode 100644
index 0000000..b9f8751
--- /dev/null
+++ b/BTCPayServer/Components/SearchStringInput/Default.cshtml
@@ -0,0 +1,6 @@
+@model BTCPayServer.Components.SearchStringInput.SearchStringInput.Model
+
+<input id="SearchTerm" name="SearchTerm" type="hidden" value="@Model.SearchString.ToString(SearchStringFormat.OnlyUIFilters)"/>
+<input id="SearchText" name="SearchText" class="form-control" value="@Model.SearchString.ToString(SearchStringFormat.ExceptUIFilters)" placeholder="@Model.Placeholder" />
+@* So hitting enter will not submit another button in the page *@
+<button type="submit" class="d-none"></button>
diff --git a/BTCPayServer/Components/SearchStringInput/SearchStringInput.cs b/BTCPayServer/Components/SearchStringInput/SearchStringInput.cs
new file mode 100644
index 0000000..043992f
--- /dev/null
+++ b/BTCPayServer/Components/SearchStringInput/SearchStringInput.cs
@@ -0,0 +1,19 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+
+namespace BTCPayServer.Components.SearchStringInput;
+
+[ViewComponent]
+public class SearchStringInput(IStringLocalizer stringLocalizer) : ViewComponent
+{
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+
+ public IViewComponentResult Invoke(SearchString searchString, string placeholder = null)
+ => View(new Model { SearchString = searchString, Placeholder = placeholder ?? StringLocalizer["Search…"] });
+
+ public class Model
+ {
+ public SearchString SearchString { get; init; }
+ public string Placeholder { get; init; }
+ }
+}
diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
index ba904b3..70dfd21 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
@@ -202,7 +202,6 @@ namespace BTCPayServer.Controllers
var store = await _StoreRepository.GetStoreByInvoiceId(i.Id);
if (store is null)
return NotFound();
-
if (!await ValidateAccessForArchivedInvoice(i))
return NotFound();
@@ -1059,10 +1058,10 @@ namespace BTCPayServer.Controllers
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)]
public async Task<IActionResult> ListInvoices(InvoicesModel? model = null)
{
- model = this.ParseListQuery(model ?? new InvoicesModel());
- var timezoneOffset = model.TimezoneOffset ?? 0;
- var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}";
- var fs = new SearchString(searchTerm, timezoneOffset);
+ model ??= new InvoicesModel();
+ var fs = model.GetSearch();
+ if (model.FilterCommand is not null)
+ return model.Redirect(Request);
string? storeId = model.StoreId;
var storeIds = new HashSet<string>();
if (storeId is not null)
@@ -1074,11 +1073,8 @@ namespace BTCPayServer.Controllers
foreach (var i in l)
storeIds.Add(i);
}
- model.Search = fs;
- model.SearchText = fs.TextCombined;
-
var apps = await _appService.GetAllApps(User.GetIdOrNull(), false, storeId);
- InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, apps, timezoneOffset);
+ InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, apps);
invoiceQuery.StoreId = storeIds.ToArray();
invoiceQuery.Take = model.Count;
invoiceQuery.Skip = model.Skip;
@@ -1113,16 +1109,18 @@ namespace BTCPayServer.Controllers
HasRefund = invoice.Refunds.Any()
});
}
+
+ ViewData.SetPageTimeZone(fs);
return View(model);
}
- private InvoiceQuery GetInvoiceQuery(SearchString fs, ListAppsViewModel.ListAppViewModel[] apps, int timezoneOffset = 0)
+ private InvoiceQuery GetInvoiceQuery(SearchString fs, ListAppsViewModel.ListAppViewModel[] apps)
{
var query = new InvoiceQuery()
{
UserId = GetUserIdForInvoiceQuery()
};
- query.FillFromSearchText(fs, timezoneOffset);
+ query.FillFromSearchText(fs);
if (fs.GetFilterArray("appid") is { } appIds)
{
var appsById = apps.ToDictionary(a => a.Id);
diff --git a/BTCPayServer/Controllers/UINotificationsController.cs b/BTCPayServer/Controllers/UINotificationsController.cs
index 5d03158..ee869ef 100644
--- a/BTCPayServer/Controllers/UINotificationsController.cs
+++ b/BTCPayServer/Controllers/UINotificationsController.cs
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Models.NotificationViewModels;
+using BTCPayServer.Services;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
@@ -21,14 +22,12 @@ namespace BTCPayServer.Controllers
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 (User.GetIdOrNull() is not string userId)
return RedirectToAction("Index", "UIHome");
- var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}";
- var fs = new SearchString(searchTerm, timezoneOffset);
+ var fs = model.GetSearch();
+ if (model.FilterCommand is not null)
+ return model.Redirect(Request);
var storeIds = fs.GetFilterArray("storeid");
var stores = await storeRepo.GetStoresByUserId(userId);
model.StoreFilterOptions = stores
@@ -52,10 +51,10 @@ namespace BTCPayServer.Controllers
SearchText = model.SearchText,
Type = fs.GetFilterArray("type"),
StoreIds = storeIds,
- Seen = model.Status == "Unread" ? false : null
+ Seen = fs.GetFilterBool("all") is true ? null : false
});
model.Items = res.Items;
-
+ ViewData.SetPageTimeZone(fs);
return View(model);
}
diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs
index cd87ba2..f1ea938 100644
--- a/BTCPayServer/Controllers/UIPaymentRequestController.cs
+++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs
@@ -9,6 +9,7 @@ using BTCPayServer.Abstractions.Form;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
+using BTCPayServer.Components.LabelSelector;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Filters;
@@ -96,14 +97,13 @@ namespace BTCPayServer.Controllers
[Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> GetPaymentRequests(string storeId, ListPaymentRequestsViewModel model = null)
{
- model = this.ParseListQuery(model ?? new ListPaymentRequestsViewModel());
+ model ??= new ListPaymentRequestsViewModel();
- var timezoneOffset = model.TimezoneOffset ?? 0;
- var fs = new SearchString(model.SearchTerm, timezoneOffset);
- var textSearch = model.SearchText;
- var startDate = fs.GetFilterDate("startdate", timezoneOffset);
- var endDate = fs.GetFilterDate("enddate", timezoneOffset);
+ var fs = model.GetSearch();
+ if (model.FilterCommand is not null)
+ return model.Redirect(Request);
+ var period = fs.GetDateRange(TimeZoneInfo.Utc);
var result = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery
{
UserId = GetUserId(),
@@ -113,14 +113,11 @@ namespace BTCPayServer.Controllers
Status = fs.GetFilterArray("status")?.Select(s => Enum.Parse<PaymentRequestStatus>(s, true)).ToArray(),
IncludeArchived = fs.GetFilterBool("includearchived") ?? false,
SearchText = model.SearchText,
- StartDate = startDate,
- EndDate = endDate,
- LabelFilter = model.LabelFilter
+ StartDate = period.StartDate,
+ EndDate = period.EndDate,
+ LabelFilter = fs.GetFilterArray("label")
});
- model.Search = fs;
- model.SearchText = textSearch;
-
var items = result.Select(data => new ViewPaymentRequestViewModel(data)
{
AmountFormatted = _displayFormatter.Currency(data.Amount, data.Currency)
@@ -149,7 +146,7 @@ namespace BTCPayServer.Controllers
var allLabels = await _storeLabelRepository.GetStoreLabels(storeId, WalletObjectData.Types.PaymentRequest);
model.Labels = allLabels
- .Select(l => new TransactionTagModel
+ .Select(l => new LabelSelectorItemViewModel()
{
Text = l.Label,
Color = l.Color,
@@ -159,6 +156,7 @@ namespace BTCPayServer.Controllers
.ToList();
model.Items = items;
+ ViewData.SetPageTimeZone(fs);
return View(model);
}
diff --git a/BTCPayServer/Controllers/UIServerController.Users.cs b/BTCPayServer/Controllers/UIServerController.Users.cs
index d027d4a..b7105e1 100644
--- a/BTCPayServer/Controllers/UIServerController.Users.cs
+++ b/BTCPayServer/Controllers/UIServerController.Users.cs
@@ -25,7 +25,7 @@ namespace BTCPayServer.Controllers
UsersViewModel model,
string sortOrder = null)
{
- model = this.ParseListQuery(model ?? new UsersViewModel());
+ model ??= new UsersViewModel();
var usersQuery = _UserManager.Users;
if (!string.IsNullOrWhiteSpace(model.SearchTerm))
diff --git a/BTCPayServer/Controllers/UIStorePullPaymentsController.PullPayments.cs b/BTCPayServer/Controllers/UIStorePullPaymentsController.PullPayments.cs
index 8bea2ec..cca9afd 100644
--- a/BTCPayServer/Controllers/UIStorePullPaymentsController.PullPayments.cs
+++ b/BTCPayServer/Controllers/UIStorePullPaymentsController.PullPayments.cs
@@ -200,12 +200,12 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId });
}
- var vm = this.ParseListQuery(new PullPaymentsModel
+ var vm = new PullPaymentsModel
{
Skip = skip,
Count = count,
ActiveState = pullPaymentState
- });
+ };
switch (pullPaymentState)
{
@@ -531,7 +531,7 @@ namespace BTCPayServer.Controllers
}
payoutMethodId ??= paymentMethods.First().ToString();
- var vm = this.ParseListQuery(new PayoutsModel
+ var vm = new PayoutsModel
{
PayoutMethods = paymentMethods,
PayoutMethodId = payoutMethodId,
@@ -541,7 +541,7 @@ namespace BTCPayServer.Controllers
Count = count,
Payouts = new List<PayoutsModel.PayoutModel>(),
HasPayoutProcessor = await HasPayoutProcessor(storeId, payoutMethodId)
- });
+ };
await using var ctx = _dbContextFactory.CreateContext();
var payoutRequest =
ctx.Payouts.Where(p => p.StoreDataId == storeId && (p.PullPaymentDataId == null || !p.PullPaymentData.Archived));
diff --git a/BTCPayServer/Controllers/UIStoresController.Settings.cs b/BTCPayServer/Controllers/UIStoresController.Settings.cs
index 537a67c..d97504f 100644
--- a/BTCPayServer/Controllers/UIStoresController.Settings.cs
+++ b/BTCPayServer/Controllers/UIStoresController.Settings.cs
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
diff --git a/BTCPayServer/Extensions/ControllerBaseExtensions.cs b/BTCPayServer/Extensions/ControllerBaseExtensions.cs
deleted file mode 100644
index 9c4f441..0000000
--- a/BTCPayServer/Extensions/ControllerBaseExtensions.cs
+++ /dev/null
@@ -1,67 +0,0 @@
-using System;
-using System.Reflection;
-using BTCPayServer.Models;
-using BTCPayServer.Models.InvoicingModels;
-using BTCPayServer.Models.PaymentRequestViewModels;
-using BTCPayServer.Models.ServerViewModels;
-using BTCPayServer.Plugins.Wallets.Views.ViewModels;
-using Microsoft.AspNetCore.Mvc;
-using Newtonsoft.Json;
-
-namespace BTCPayServer
-{
- // Classes here remember users preferences on certain pages and store them in unified blob cookie "UserPrefsCookie"
- 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 if (model is UsersViewModel)
- prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.UsersQuery));
- else if (model is PayoutsModel)
- prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.PayoutsQuery));
- else if (model is PullPaymentsModel)
- prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.PullPaymentsQuery));
- else if (model is ListTransactionsViewModel)
- prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.WalletTransactionsQuery));
- 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 = ctrl.HttpContext.GetUserPrefsCookie();
-
- // 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 not null ? 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;
- model.Count = section.Count ?? BasePagingViewModel.CountDefault;
- }
- }
- else
- {
- prop.SetValue(prefCookie, new ListQueryDataHolder(model.SearchTerm, model.TimezoneOffset, model.Count));
- ctrl.Response.Cookies.Append(nameof(UserPrefsCookie), JsonConvert.SerializeObject(prefCookie));
- }
-
- return model;
- }
- }
-}
diff --git a/BTCPayServer/Extensions/UserPrefsCookie.cs b/BTCPayServer/Extensions/UserPrefsCookie.cs
index 4cafb30..528d1a5 100644
--- a/BTCPayServer/Extensions/UserPrefsCookie.cs
+++ b/BTCPayServer/Extensions/UserPrefsCookie.cs
@@ -2,28 +2,6 @@ namespace BTCPayServer
{
public class UserPrefsCookie
{
- public ListQueryDataHolder InvoicesQuery { get; set; }
- public ListQueryDataHolder PaymentRequestsQuery { get; set; }
- public ListQueryDataHolder UsersQuery { get; set; }
- public ListQueryDataHolder PayoutsQuery { get; set; }
- public ListQueryDataHolder PullPaymentsQuery { get; set; }
- public ListQueryDataHolder WalletTransactionsQuery { get; set; }
public string CurrentStoreId { get; set; }
}
-
- public class ListQueryDataHolder
- {
- public ListQueryDataHolder() { }
-
- public ListQueryDataHolder(string searchTerm, int? timezoneOffset, int? count)
- {
- SearchTerm = searchTerm;
- TimezoneOffset = timezoneOffset;
- Count = count;
- }
-
- public int? TimezoneOffset { get; set; }
- public string SearchTerm { get; set; }
- public int? Count { get; set; }
- }
}
diff --git a/BTCPayServer/Models/BasePagingViewModel.cs b/BTCPayServer/Models/BasePagingViewModel.cs
index 356f1f3..b0f2880 100644
--- a/BTCPayServer/Models/BasePagingViewModel.cs
+++ b/BTCPayServer/Models/BasePagingViewModel.cs
@@ -1,5 +1,15 @@
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.Primitives;
namespace BTCPayServer.Models
{
@@ -10,11 +20,114 @@ namespace BTCPayServer.Models
public int Skip { get; set; } = 0;
public int Count { get; set; } = CountDefault;
public int? Total { get; set; }
+
+
+ /// <summary>
+ /// SearchTerm is the part of the SearchString that isn't shown explicitly
+ /// in the search text input.
+ /// </summary>
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string SearchTerm { get; set; }
- public int? TimezoneOffset { get; set; }
+
+ [BindingBehavior(BindingBehavior.Never)]
+ [ValidateNever]
+ public SearchString Search { get; set; }
+
+ /// <summary>
+ /// SearchText is the part of the SearchString that is shown explicitly in the search text input.
+ /// </summary>
+ public string SearchText { get; set; }
+ public string FilterCommand { get; set; }
+
+ public string TimeZone { get; set; }
+
+ public SearchString GetSearch()
+ {
+ var search = SearchString.Combine([SearchTerm, SearchText]);
+ if (TimeZone is not null)
+ {
+ search.SetFilter("timezone", TimeZones.TryGet(TimeZone)?.Id);
+ }
+ if (FilterCommand is not null)
+ RunFilterCommand(search);
+ AddUIFilters(search);
+ SearchTerm = search.ToString(SearchStringFormat.OnlyUIFilters);
+ SearchText = search.ToString(SearchStringFormat.ExceptUIFilters);
+ Search = search;
+ return search;
+ }
+
+ protected virtual void AddUIFilters(SearchString search)
+ {
+ }
+
+ protected virtual void RunFilterCommand(SearchString search)
+ {
+ if (Regex.Match(FilterCommand, @"^(set|set-multi):") is { Success: true } match)
+ {
+ var kv = FilterCommand.Substring(match.Value.Length).Split('=', 2);
+ if (kv.Length == 2)
+ {
+ search.SetFilter(kv[0], kv[1], true, "set-multi:" == match.Value);
+ }
+ }
+
+ if (FilterCommand.StartsWith("unset:"))
+ {
+ var k = FilterCommand.Substring("unset:".Length);
+ search.SetFilter(k);
+ }
+
+ if (FilterCommand is "reset")
+ {
+ search.Filters.Clear();
+ search.TextSearch = "";
+ return;
+ }
+
+ if (FilterCommand is "alltime")
+ {
+ search.Filters.Remove("daterange");
+ search.Filters.Remove("startdate");
+ search.Filters.Remove("enddate");
+ }
+
+ if (FilterCommand.StartsWith("set-daterange:"))
+ {
+ var dateRange = FilterCommand.Substring("set-daterange:".Length);
+ if (SearchString.IsValidDateRange(dateRange))
+ search.SetDateRange(dateRange, true);
+ }
+ }
+
+ public IActionResult Redirect(HttpRequest request)
+ {
+ var query = QueryHelpers.ParseQuery(request.QueryString.Value);
+
+ var newQuery = query.ToDictionary(
+ x => x.Key,
+ x => x.Value,
+ StringComparer.OrdinalIgnoreCase);
+
+ newQuery.Remove("SearchTerm");
+ newQuery.Remove("SearchText");
+ newQuery.Remove("Skip");
+
+ if (!string.IsNullOrEmpty(SearchTerm))
+ newQuery["SearchTerm"] = new StringValues(SearchTerm);
+ if (!string.IsNullOrEmpty(SearchText))
+ newQuery["SearchText"] = new StringValues(SearchText);
+ newQuery.Remove("FilterCommand");
+
+ var path = (request.PathBase + request.Path).ToString();
+ var newUrl = QueryHelpers.AddQueryString(path, newQuery);
+ return new LocalRedirectResult(newUrl);
+ }
+
public Dictionary<string, object> PaginationQuery { get; set; }
+ [ValidateNever]
+ [BindingBehavior(BindingBehavior.Never)]
public abstract int CurrentPageCount { get; }
}
}
diff --git a/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs b/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs
index 70a6af1..b57c4ef 100644
--- a/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs
+++ b/BTCPayServer/Models/InvoicingModels/InvoicesModel.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Services.Invoices;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
namespace BTCPayServer.Models.InvoicingModels
{
@@ -9,8 +10,6 @@ namespace BTCPayServer.Models.InvoicingModels
public List<InvoiceModel> Invoices { get; set; } = new ();
public override int CurrentPageCount => Invoices.Count;
public string StoreId { get; set; }
- public string SearchText { get; set; }
- public SearchString Search { get; set; }
public List<InvoiceAppModel> Apps { get; set; }
}
@@ -31,7 +30,7 @@ namespace BTCPayServer.Models.InvoicingModels
public InvoiceDetailsModel Details { get; set; }
public bool HasRefund { get; set; }
}
-
+
public class InvoiceAppModel
{
public string Id { get; set; }
diff --git a/BTCPayServer/Models/NotificationViewModels/IndexViewModel.cs b/BTCPayServer/Models/NotificationViewModels/IndexViewModel.cs
deleted file mode 100644
index e62e61f..0000000
--- a/BTCPayServer/Models/NotificationViewModels/IndexViewModel.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using System.Collections.Generic;
-using BTCPayServer.Abstractions.Contracts;
-
-namespace BTCPayServer.Models.NotificationViewModels
-{
- public class IndexViewModel : BasePagingViewModel
- {
- 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<StoreFilterOption> StoreFilterOptions { get; set; }
- }
-
- public class StoreFilterOption
- {
- public bool Selected { get; set; }
- public string Text { get; set; }
- public string Value { get; set; }
- }
-}
diff --git a/BTCPayServer/Models/NotificationViewModels/NotificationIndexViewModel.cs b/BTCPayServer/Models/NotificationViewModels/NotificationIndexViewModel.cs
new file mode 100644
index 0000000..8d7cfb1
--- /dev/null
+++ b/BTCPayServer/Models/NotificationViewModels/NotificationIndexViewModel.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using BTCPayServer.Abstractions.Contracts;
+
+namespace BTCPayServer.Models.NotificationViewModels
+{
+ public class NotificationIndexViewModel : BasePagingViewModel
+ {
+ public List<NotificationViewModel> Items { get; set; } = [];
+ public override int CurrentPageCount => Items.Count;
+ public List<StoreFilterOption> StoreFilterOptions { get; set; }
+ protected override void AddUIFilters(SearchString search)
+ {
+ base.AddUIFilters(search);
+ search.UIFilterTypes.Add("type");
+ search.UIFilterTypes.Add("storeid");
+ search.UIFilterTypes.Add("all");
+ }
+ }
+
+ public class StoreFilterOption
+ {
+ public bool Selected { get; set; }
+ public string Text { get; set; }
+ public string Value { get; set; }
+ }
+}
diff --git a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs
index 9fa07d0..ada772d 100644
--- a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs
+++ b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Client.Models;
+using BTCPayServer.Components.LabelSelector;
using BTCPayServer.Data;
using BTCPayServer.Plugins.Wallets.Views.ViewModels;
using BTCPayServer.Services;
@@ -19,11 +20,20 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
public List<ViewPaymentRequestViewModel> Items { get; set; }
public override int CurrentPageCount => Items.Count;
- public SearchString Search { get; set; }
- public string SearchText { get; set; }
public string WalletId { get; set; }
- public string LabelFilter { get; set; }
- public List<TransactionTagModel> Labels { get; set; } = new();
+ public List<LabelSelectorItemViewModel> Labels { get; set; } = new();
+
+ protected override void AddUIFilters(SearchString search)
+ {
+ base.AddUIFilters(search);
+ LabelSelector.AddUIFilters(search);
+ }
+
+ protected override void RunFilterCommand(SearchString search)
+ {
+ base.RunFilterCommand(search);
+ LabelSelector.RunFilterCommand(search, FilterCommand);
+ }
}
public class UpdatePaymentRequestViewModel
diff --git a/BTCPayServer/Plugins/GlobalSearch/Controllers/UISearchController.cs b/BTCPayServer/Plugins/GlobalSearch/Controllers/UISearchController.cs
index 4fc484b..d18180e 100644
--- a/BTCPayServer/Plugins/GlobalSearch/Controllers/UISearchController.cs
+++ b/BTCPayServer/Plugins/GlobalSearch/Controllers/UISearchController.cs
@@ -1,7 +1,9 @@
+using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Models;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
diff --git a/BTCPayServer/Plugins/GlobalSearch/ISearchResultItemProvider.cs b/BTCPayServer/Plugins/GlobalSearch/ISearchResultItemProvider.cs
index 25e23ff..99cea96 100644
--- a/BTCPayServer/Plugins/GlobalSearch/ISearchResultItemProvider.cs
+++ b/BTCPayServer/Plugins/GlobalSearch/ISearchResultItemProvider.cs
@@ -1,4 +1,5 @@
#nullable enable
+using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
diff --git a/BTCPayServer/Plugins/GlobalSearch/InvoiceSearchResultProvider.cs b/BTCPayServer/Plugins/GlobalSearch/InvoiceSearchResultProvider.cs
index 61275dc..c14fd4a 100644
--- a/BTCPayServer/Plugins/GlobalSearch/InvoiceSearchResultProvider.cs
+++ b/BTCPayServer/Plugins/GlobalSearch/InvoiceSearchResultProvider.cs
@@ -1,9 +1,11 @@
+using System;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Plugins.GlobalSearch.Views;
+using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
@@ -20,8 +22,9 @@ public class InvoiceSearchResultProvider(InvoiceRepository invoice,
if (context is { UserQuery: string q, Store: not null })
{
var search = new SearchString(q);
+ search.Filters.Clear();
var invQuery = new InvoiceQuery();
- invQuery.FillFromSearchText(search, 0);
+ invQuery.FillFromSearchText(search);
invQuery.StoreId = [context.Store.Id];
invQuery.UserId = context.UserId;
invQuery.Take = (context.MaxResult ?? 10);
diff --git a/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs b/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs
index 7bff13e..95aa8ed 100644
--- a/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs
+++ b/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs
@@ -6,6 +6,7 @@ using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
+using BTCPayServer.Models;
using BTCPayServer.Plugins.GlobalSearch.Views;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authorization;
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/Public/_Layout.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/Public/_Layout.cshtml
index 2a0fec9..6116b78 100644
--- a/BTCPayServer/Plugins/PointOfSale/Views/Public/_Layout.cshtml
+++ b/BTCPayServer/Plugins/PointOfSale/Views/Public/_Layout.cshtml
@@ -11,7 +11,7 @@
ViewData["Title"] = string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title;
ViewData["StoreBranding"] = Model.StoreBranding;
Layout = null;
-
+
async Task<string> GetDynamicManifest(string title)
{
var settings = await SettingsRepository.GetSettingAsync<ServerSettings>() ?? new ServerSettings();
@@ -42,6 +42,7 @@
<link rel="manifest" href="@(await GetDynamicManifest(ViewData["Title"]!.ToString()))">
<link href="~/pos/common.css" asp-append-version="true" rel="stylesheet" />
@this.Safe.Meta(Model.HtmlMetaTags)
+ <vc:declare-date-formatter-options></vc:declare-date-formatter-options>
@await RenderSectionAsync("PageHeadContent", false)
</head>
<body class="min-vh-100">
diff --git a/BTCPayServer/Plugins/Translations/Translations.Default.cs b/BTCPayServer/Plugins/Translations/Translations.Default.cs
index e431ea9..36cabcc 100644
--- a/BTCPayServer/Plugins/Translations/Translations.Default.cs
+++ b/BTCPayServer/Plugins/Translations/Translations.Default.cs
@@ -491,6 +491,7 @@ namespace BTCPayServer.Plugins.Translations
"Custom mode overrides the standard path completely.": "",
"Custom Payments": "",
"Custom Range": "",
+ "Custom range": "",
"Custom sound file for successful payment": "",
"Custom text displayed on the checkout page below the payment details. Plain text only, newlines are supported.": "",
"Custom Theme Extension Type": "",
@@ -934,15 +935,16 @@ namespace BTCPayServer.Plugins.Translations
"Language": "",
"Language pack '{0}' downloaded successfully": "",
"Language pack '{0}' updated successfully": "",
- "Last 24 hours": "",
- "Last 3 days": "",
- "Last 7 days": "",
"Last delivery {0}": "",
"Last error:": "",
+ "Last month": "",
+ "Last quarter": "",
"Last updated": "",
"Last Updated": "",
"Last used": "",
"Last Used": "",
+ "Last week": "",
+ "Last year": "",
"Learn More": "",
"Leave blank to generate ID from title.": "",
"Leave blank to not apply tax to tips.": "",
@@ -1955,6 +1957,7 @@ namespace BTCPayServer.Plugins.Translations
"This label will be deleted from this wallet and its associated transactions.": "",
"This Lightning Address will be removed.": "",
"this link": "",
+ "This month": "",
"This offering will be removed from this store.": "",
"This page exposes information to connect remotely to your full node via the P2P protocol.": "",
"This page exposes information to connect remotely to your full node via the RPC protocol.": "",
@@ -1965,6 +1968,7 @@ namespace BTCPayServer.Plugins.Translations
"This process disables 2FA until you verify your authenticator app. If you do not complete your authenticator app configuration you may lose access to your account.": "",
"This processor cannot handle {0}.": "",
"This pull payment does not exists": "",
+ "This quarter": "",
"This QR Code is only valid for 10 minutes": "",
"This rate can also be overridden per item.": "",
"This signer key is already used in this multisig request.": "",
@@ -1975,11 +1979,13 @@ namespace BTCPayServer.Plugins.Translations
"This translation will be removed from this server.": "",
"This version of NBXplorer is not compatible. Please update to 2.5.22 or above": "",
"This webhook will be removed from this store.": "",
+ "This week": "",
"This will approve the user <strong>{0}</strong>.": "",
"This will send a verification email to <strong>{0}</strong>.": "",
"This will send a verification email to the user.": "",
"This will send notification mails to the recipient, as configured by the <a href=\"{0}\">email rules</a>.": "",
"This will unapprove the user <strong>{0}</strong>.": "",
+ "This year": "",
"Those initial settings can be modified later.": "",
"Threshold": "",
"Timestamp": "",
@@ -2008,6 +2014,7 @@ namespace BTCPayServer.Plugins.Translations
"Toggle password visibility": "",
"Toggle seed visibility": "",
"Token": "",
+ "Today": "",
"Token Information": "",
"Top Items": "",
"Top Perks": "",
@@ -2222,6 +2229,7 @@ namespace BTCPayServer.Plugins.Translations
"Would you like to proceed with suspending the following user?": "",
"Would you like to refund <span class=\"subscriber-name fw-semibold\"></span>?": "",
"Would you like to upgrade <span class=\"subscriber-name fw-semibold\"></span> to <b class=\"changePlanName\"></b>?": "",
+ "Yesterday": "",
"Yes": "",
"You are invited": "",
"You are not a signer": "",
diff --git a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
index e76a44b..6f5564f 100644
--- a/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Plugins/Wallets/Controllers/UIWalletsController.cs
@@ -15,6 +15,7 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.BIP78.Sender;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
+using BTCPayServer.Components.LabelSelector;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.ModelBinders;
@@ -587,10 +588,9 @@ namespace BTCPayServer.Controllers
internal sealed class WalletTransactionsFilter
{
- public SearchString Search { get; init; } = new(string.Empty);
+ public required SearchString Search { get; init; }
public string SearchTerm { get; init; } = string.Empty;
public string SearchText { get; init; } = string.Empty;
- public string SearchInputText { get; init; } = string.Empty;
public string TextSearch { get; init; } = string.Empty;
public DateTimeOffset? StartDate { get; init; }
public DateTimeOffset? EndDate { get; init; }
@@ -601,21 +601,9 @@ namespace BTCPayServer.Controllers
public bool HasFilters => !string.IsNullOrWhiteSpace(SearchText) || StartDate is not null || EndDate is not null || HasLabelFilter || Positive is not null;
}
- internal static WalletTransactionsFilter BuildWalletTransactionsFilter(string? searchTerm, string? searchText, string? labelFilter, int timezoneOffset)
+ internal static WalletTransactionsFilter BuildWalletTransactionsFilter(SearchString search)
{
- var combinedSearchTerm = string.IsNullOrEmpty(searchText) ? searchTerm : $"{searchText},{searchTerm}";
- var search = new SearchString(combinedSearchTerm, timezoneOffset);
- var normalizedSearchTerm = search.ToString();
- if (string.IsNullOrWhiteSpace(normalizedSearchTerm) || normalizedSearchTerm == " ")
- {
- search = new SearchString(string.Empty, timezoneOffset);
- }
var labelFilters = new List<string>(search.GetFilterArray("label") ?? Array.Empty<string>());
- if (!string.IsNullOrWhiteSpace(labelFilter))
- {
- labelFilters.Add(labelFilter);
- }
-
var includeNoLabel = search.GetFilterBool("nolabel") is true;
labelFilters = labelFilters
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -638,15 +626,15 @@ namespace BTCPayServer.Controllers
}
}
+ var period = search.GetDateRange(TimeZoneInfo.Utc);
return new WalletTransactionsFilter
{
Search = search,
- SearchTerm = search.WithoutSearchText(),
+ SearchTerm = search.ToString(SearchStringFormat.OnlyUIFilters),
SearchText = textSearch,
- SearchInputText = textSearch,
TextSearch = textSearch,
- StartDate = search.GetFilterDate("startdate", timezoneOffset),
- EndDate = search.GetFilterDate("enddate", timezoneOffset),
+ StartDate = period.StartDate,
+ EndDate = period.EndDate,
LabelFilters = labelFilters,
IncludeNoLabel = includeNoLabel,
Positive = positive
@@ -722,52 +710,46 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> WalletTransactions(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId,
- string? labelFilter = null,
bool loadTransactions = false,
ListTransactionsViewModel? model = null,
CancellationToken cancellationToken = default
)
{
- model = this.ParseListQuery(model ?? new ListTransactionsViewModel());
+ model ??= new();
var paymentMethod = GetDerivationSchemeSettings(walletId);
if (paymentMethod == null)
return NotFound();
var network = handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
var wallet = walletProvider.GetWallet(network);
- var effectiveTimezoneOffset = model.TimezoneOffset ?? 0;
- var filter = BuildWalletTransactionsFilter(model.SearchTerm, model.SearchText, labelFilter, effectiveTimezoneOffset);
+
+ var fs = model.GetSearch();
+ if (model.FilterCommand is not null)
+ return model.Redirect(Request);
+ var filter = BuildWalletTransactionsFilter(fs);
var filterAtSource = !filter.HasFilters;
var requiresMetadataFiltering = RequiresWalletTransactionMetadataFiltering(filter);
- model.Search = filter.Search;
model.SearchText = filter.SearchText;
- model.SearchInputText = filter.SearchInputText;
model.SearchTerm = filter.SearchTerm;
- model.TimezoneOffset = effectiveTimezoneOffset;
- model.HasFilters = filter.HasFilters || !string.IsNullOrWhiteSpace(labelFilter);
+ model.HasFilters = filter.HasFilters;
model.PaginationQuery = new Dictionary<string, object>
{
{ "searchTerm", filter.SearchTerm },
- { "searchText", filter.SearchText },
- { "timezoneOffset", effectiveTimezoneOffset }
+ { "searchText", filter.SearchText }
};
- if (!string.IsNullOrEmpty(labelFilter))
- model.PaginationQuery.Add("labelFilter", labelFilter);
model.PendingTransactions = await pendingTransactionService.GetPendingTransactions(walletId.CryptoCode, walletId.StoreId);
model.Rates = GetCurrentStore().GetStoreBlob().GetTrackedRates().ToList();
- const int maxVisibleLabels = 20;
var labelsWithUsage = await WalletRepository.GetWalletLabelsByLinkedTypeWithUsage(walletId, WalletObjectData.Types.Tx, includeUnusedLabels: true);
model.Labels.AddRange(labelsWithUsage
- .Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color), c.UsageCount)));
- model.PopularLabels = labelsWithUsage
- .OrderByDescending(c => c.UsageCount)
- .ThenBy(c => c.Label, StringComparer.OrdinalIgnoreCase)
- .Take(maxVisibleLabels)
- .OrderBy(c => c.Label, StringComparer.OrdinalIgnoreCase)
- .Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color), c.UsageCount))
- .ToList();
+ .Select(c => new LabelSelectorItemViewModel()
+ {
+ Text = c.Label,
+ Color = c.Color,
+ TextColor = ColorPalette.Default.TextColor(c.Color),
+ UsageCount = c.UsageCount
+ }));
IList<TransactionHistoryLine>? transactions = null;
Dictionary<string, WalletTransactionInfo>? walletTransactionsInfo = null;
@@ -870,6 +852,7 @@ namespace BTCPayServer.Controllers
model.CryptoCode = walletId.CryptoCode;
//If ajax call then load the partial view
+ ViewData.SetPageTimeZone(fs);
return Request.Headers["X-Requested-With"] == "XMLHttpRequest"
? PartialView("_WalletTransactionsList", model)
: View(model);
@@ -2094,10 +2077,8 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> Export(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
string format,
- string? labelFilter = null,
string? searchTerm = null,
string? searchText = null,
- int? timezoneOffset = null,
CancellationToken cancellationToken = default)
{
var paymentMethod = GetDerivationSchemeSettings(walletId);
@@ -2106,12 +2087,13 @@ namespace BTCPayServer.Controllers
var network = handlers.GetBitcoinHandler(walletId.CryptoCode).Network;
var wallet = walletProvider.GetWallet(network);
- var effectiveTimezoneOffset = timezoneOffset ?? 0;
- var filter = BuildWalletTransactionsFilter(searchTerm, searchText, labelFilter, effectiveTimezoneOffset);
+
+ var fs = SearchString.Combine([searchTerm, searchText]);
+ var filter = BuildWalletTransactionsFilter(fs);
var requiresMetadataFiltering = RequiresWalletTransactionMetadataFiltering(filter);
var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, cancellationToken: cancellationToken);
- if (filter.HasFilters || !string.IsNullOrWhiteSpace(labelFilter))
+ if (filter.HasFilters)
{
input = input
.Where(tx => MatchesWalletTransactionBasicFilter(tx, filter, network))
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
index 04a2dce..360ee9d 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
@@ -1,34 +1,19 @@
-@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Client
-@using BTCPayServer.Components
-@using BTCPayServer.Components.WalletNav
-@using BTCPayServer.TagHelpers
@using System
@using Microsoft.AspNetCore.Html
-@using Microsoft.AspNetCore.Mvc.TagHelpers
@model ListTransactionsViewModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
var storeId = Context.GetRouteValue("storeId")?.ToString();
var cryptoCode = Context.GetRouteValue("cryptoCode")?.ToString();
- var labelFilter = Context.Request.Query["labelFilter"].ToString();
var wallet = walletId != null ? WalletId.Parse(walletId) : new WalletId(storeId, cryptoCode);
storeId = wallet.StoreId;
- var hasDateFilter = Model.Search?.ContainsFilter("startdate") is true || Model.Search?.ContainsFilter("enddate") is true;
- var hasNoLabelFilter = Model.Search?.ContainsFilter("nolabel") is true && Model.Search.GetFilterBool("nolabel") is true;
- var searchLabelFilters = Model.Search?.GetFilterArray("label") ?? Array.Empty<string>();
- var hasLegacyLabelFilter = !string.IsNullOrEmpty(labelFilter) && !searchLabelFilters.Any(value => value.Equals(labelFilter, StringComparison.OrdinalIgnoreCase));
- var labelFilterCount = searchLabelFilters.Length + (hasLegacyLabelFilter ? 1 : 0) + (hasNoLabelFilter ? 1 : 0);
- var directionFilters = Model.Search?.GetFilterArray("direction") ?? Array.Empty<string>();
+ var directionFilters = Model.Search.GetFilterArray("direction") ?? Array.Empty<string>();
var hasIncomingFilter = directionFilters.Any(value => value.Equals("in", StringComparison.OrdinalIgnoreCase));
var hasOutgoingFilter = directionFilters.Any(value => value.Equals("out", StringComparison.OrdinalIgnoreCase));
var hasIncomingOnlyFilter = hasIncomingFilter && !hasOutgoingFilter;
var hasOutgoingOnlyFilter = hasOutgoingFilter && !hasIncomingFilter;
- const int maxVisibleLabels = 20;
- var sortedLabels = Model.Labels.OrderBy(l => l.Text, StringComparer.OrdinalIgnoreCase).ToList();
- var popularLabels = Model.PopularLabels.OrderBy(l => l.Text, StringComparer.OrdinalIgnoreCase).ToList();
- var initialLabels = popularLabels.Any() ? popularLabels : sortedLabels.Take(maxVisibleLabels).ToList();
ViewData.SetLayoutModel(new LayoutModel($"{nameof(WalletsNavPages.Transactions)}-{Model.CryptoCode}", StringLocalizer["{0} Transactions", Model.CryptoCode])
.SetCategory(WellKnownCategories.ForWallet(Model.CryptoCode)));
}
@@ -53,19 +38,8 @@
max-width: 320px;
}
- #TransactionsToolbar {
- align-items: flex-start;
- }
-
- #WalletTransactionsSearch {
- flex: 1 1 52rem;
- min-width: 0;
- }
-
#Dropdowns {
align-items: center;
- justify-content: flex-start;
- min-width: 0;
}
#Export {
@@ -100,68 +74,12 @@
}
}
- #LabelDropdownMenu {
- max-height: 480px;
- overflow-y: auto;
- scrollbar-width: thin;
- }
-
- #LabelDropdownMenu #LabelSearchContainer .input-group:focus-within {
- border-color: var(--btcpay-form-border-focus) !important;
- }
-
- #LabelDropdownMenu #LabelSearchContainer {
- position: sticky;
- top: 0;
- z-index: 1;
- background: var(--btcpay-dropdown-bg);
- border-bottom: var(--bs-border-width, 1px) solid var(--bs-border-color, rgba(0,0,0,.175));
- }
-
- #LabelDropdownMenu .label-filter-link {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 0.5rem;
- }
-
- #LabelDropdownMenu .label-filter-count {
- margin-left: auto;
- text-align: right;
- opacity: 0.75;
- }
-
.dropdown > .btn {
min-width: 7rem;
padding-left: 1rem;
text-align: left;
}
- @@media (max-width: 1399.98px) {
- #WalletTransactionsSearch,
- #Dropdowns {
- width: 100%;
- flex-basis: 100%;
- }
-
- #SearchText {
- width: 100%;
- max-width: none;
- flex-basis: 100%;
- }
- }
-
- @@media (min-width: 1400px) {
- #TransactionsToolbar {
- align-items: center;
- }
-
- #Dropdowns {
- margin-left: auto;
- justify-content: flex-end;
- }
- }
-
#LoadingIndicator {
margin-bottom: 1.5rem;
}
@@ -171,107 +89,24 @@
@section PageFootContent {
@*Script must be async to ensure proper page loading*@
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
- @* Custom Range Modal *@
<script>
const $actions = document.getElementById('ListActions');
const $transactions = document.getElementById('WalletTransactions');
const $list = document.getElementById('WalletTransactionsList');
const $dropdowns = document.getElementById('Dropdowns');
const $indicator = document.getElementById('LoadingIndicator');
- const $walletTransactionsSearch = document.getElementById('WalletTransactionsSearch');
- const $searchTerm = $walletTransactionsSearch?.querySelector('input[name="SearchTerm"]');
- const $searchText = document.getElementById('SearchText');
delegate('click', '#GoToTop', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
- function setSearchFilters(search, key, values = []) {
- const filters = (search || '')
- .split(',')
- .map(value => value.trim())
- .filter(value => value && !value.toLowerCase().startsWith(`${key.toLowerCase()}:`));
-
- values
- .filter(value => value)
- .forEach(value => filters.push(`${key}:${value}`));
-
- return filters.join(',');
- }
-
- const $labelSearch = document.getElementById('LabelSearch');
- if ($labelSearch) {
- const allLabels = @Safe.Json(sortedLabels.Select(label => new { text = label.Text, color = label.Color, textColor = label.TextColor, usageCount = label.UsageCount }));
- const initialLabels = @Safe.Json(initialLabels.Select(label => new { text = label.Text, color = label.Color, textColor = label.TextColor, usageCount = label.UsageCount }));
- const activeLabels = @Safe.Json(searchLabelFilters.Concat(string.IsNullOrEmpty(labelFilter) ? Array.Empty<string>() : new[] { labelFilter }).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
- const $menu = document.getElementById('LabelDropdownMenu');
- const $anchor = document.getElementById('LabelFilterResultsAnchor');
-
- function renderLabelItems(labels) {
- $menu.querySelectorAll('.label-filter-item').forEach(el => el.remove());
- const fragment = document.createDocumentFragment();
- const $template = document.getElementById('label-filter-item-template');
-
- labels.forEach(label => {
- const labelFilterItem = $template.content.cloneNode(true);
- const a = labelFilterItem.querySelector('a');
- const labelText = a.querySelector('.label-filter-text');
- const labelCount = a.querySelector('.label-filter-count');
- a.href = a.getAttribute('href').replace('LABEL_PLACEHOLDER', encodeURIComponent(label.text));
- if (activeLabels.some(value => value.toLowerCase() === label.text.toLowerCase())) a.classList.add('active');
- a.style.setProperty('--btcpay-dropdown-link-active-bg', label.color);
- a.style.setProperty('--btcpay-dropdown-link-active-color', label.textColor);
- labelText.textContent = label.text;
- if (label.usageCount > 0) {
- labelCount.classList.remove('d-none');
- labelCount.textContent = label.usageCount;
- } else {
- labelCount.classList.add('d-none');
- labelCount.textContent = '';
- }
- fragment.appendChild(labelFilterItem);
- });
-
- $anchor.after(fragment);
- }
-
- $labelSearch.addEventListener('click', e => e.stopPropagation());
- $labelSearch.addEventListener('input', () => {
- const query = $labelSearch.value.toLowerCase().trim();
- renderLabelItems(query ? allLabels.filter(label => label.text.toLowerCase().includes(query)) : initialLabels);
- });
- }
-
if ($actions && $actions.offsetTop - window.innerHeight > 0) {
document.getElementById('GoToTop').classList.remove('d-none');
}
- document.querySelectorAll('[data-direction-filter]').forEach($item => {
- $item.addEventListener('click', () => {
- if (!$walletTransactionsSearch || !$searchTerm) return;
-
- const direction = $item.dataset.directionFilter;
- $searchTerm.value = setSearchFilters($searchTerm.value, 'direction', direction ? [direction] : []);
- $walletTransactionsSearch.submit();
- });
- });
-
- if ($searchText && $walletTransactionsSearch) {
- $searchText.addEventListener('keydown', event => {
- if (event.key !== 'Enter') return;
-
- event.preventDefault();
- if (typeof $walletTransactionsSearch.requestSubmit === 'function') {
- $walletTransactionsSearch.requestSubmit();
- } else {
- $walletTransactionsSearch.submit();
- }
- });
- }
-
const count = @Safe.Json(Model.Count);
const skipInitial = @Safe.Json(Model.Skip);
- const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new {walletId, labelFilter, searchTerm = Model.SearchTerm, searchText = Model.SearchText, timezoneOffset = Model.TimezoneOffset, skip = Model.Skip, count = Model.Count, loadTransactions = true}));
+ const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new {walletId, searchTerm = Model.SearchTerm, searchText = Model.SearchText, skip = Model.Skip, count = Model.Count, loadTransactions = true}));
// The next time we load transactions, skip will become 0
let skip = @Safe.Json(Model.Skip) - count;
@@ -279,7 +114,7 @@
$indicator.classList.remove('d-none');
const skipNext = skip + count;
- const url = loadMoreUrl.replace(`skip=${skipInitial}`, `skip=${skipNext}`)
+ const url = new URL(loadMoreUrl.replace(`skip=${skipInitial}`, `skip=${skipNext}`), window.location.origin);
const response = await fetch(url, {
headers: {
'Accept': 'text/html',
@@ -302,81 +137,18 @@
// replace table and dropdowns if initial response was empty
if (responseEmpty) {
$dropdowns.remove();
- $transactions.innerHTML = '<div class="text-secondary" data-loaded="true">There are no transactions yet.</div>';
+ $transactions.innerHTML = @Safe.Json(StringLocalizer["<div class=\"text-secondary\" data-loaded=\"true\">There are no transactions yet.</div>"].Value);
}
}
}
$indicator.classList.add('d-none');
- formatDateTimes(document.querySelector('#WalletTransactions .switch-time-format').dataset.mode);
+ var switchTime = document.querySelector('#WalletTransactions .switch-time-format');
+ if (switchTime)
+ formatDateTimes(switchTime.dataset.mode);
initLabelManagers();
}
- $('#btnCustomRangeDate').on('click', function () {
- const dtpStartDate = $("#dtpStartDate").val();
- const dtpEndDate = $("#dtpEndDate").val();
-
- if (!dtpStartDate && !dtpEndDate) {
- $("#dtpStartDate").next().trigger("focus");
- return;
- }
-
- const dateFilters = [];
- if (dtpStartDate) {
- dateFilters.push(`startdate:${dtpStartDate}`);
- }
- if (dtpEndDate) {
- dateFilters.push(`enddate:${dtpEndDate}`);
- }
-
- const currentSearchTerm = $('#WalletTransactionsSearch input[name="SearchTerm"]').val() || '';
- const cleanedSearchTerm = currentSearchTerm
- .split(',')
- .map(value => value.trim())
- .filter(value => value && !value.toLowerCase().startsWith('startdate:') && !value.toLowerCase().startsWith('enddate:'))
- .join(',');
- const combinedSearchTerm = [cleanedSearchTerm, dateFilters.join(',')].filter(Boolean).join(',');
-
- const baseUrl = "@Url.Action("WalletTransactions", new { walletId, labelFilter })";
- const url = new URL(baseUrl, window.location.origin);
- url.searchParams.set("Count", $("#Count").val());
- url.searchParams.set("timezoneOffset", $("#TimezoneOffset").val());
-
- if (combinedSearchTerm) {
- url.searchParams.set("SearchTerm", combinedSearchTerm);
- } else {
- url.searchParams.delete("SearchTerm");
- }
-
- const searchText = $('#SearchText').val() || '';
- if (searchText) {
- url.searchParams.set("SearchText", searchText);
- } else {
- url.searchParams.delete("SearchText");
- }
-
- window.location.href = url.toString();
- });
-
- const clearBtn = document.getElementById('clearAllFiltersBtn');
- if (clearBtn) {
- clearBtn.addEventListener('click', function () {
- const form = clearBtn.closest('form');
- if (!form) return;
-
- const searchTextInput = form.querySelector('[name="SearchText"]');
- if (searchTextInput) searchTextInput.value = '';
-
- const searchTermInput = form.querySelector('[name="SearchTerm"]');
- if (searchTermInput) searchTermInput.value = '';
-
- const labelFilterInput = form.querySelector('[name="labelFilter"]');
- if (labelFilterInput) labelFilterInput.value = '';
-
- form.submit();
- });
- }
-
const observer = new IntersectionObserver(async entries => {
const { isIntersecting } = entries[0];
if (isIntersecting) {
@@ -391,232 +163,6 @@
<div class="sticky-header">
<vc:wallet-nav wallet-id="wallet"/>
-</div>
-<partial name="_StatusMessage" />
-
-@functions
-{
- private bool HasArrayFilter(string type, string key = null) =>
- Model.Search?.ContainsFilter(type) is true && (key is null || Model.Search.GetFilterArray(type).Contains(key));
-
- private bool HasBooleanFilter(string key) =>
- Model.Search?.ContainsFilter(key) is true && Model.Search.GetFilterBool(key) is true;
-
- private bool HasCustomDateFilter() =>
- Model.Search?.ContainsFilter("enddate") is true ||
- (Model.Search?.ContainsFilter("startdate") is true &&
- !HasArrayFilter("startdate", "-1d") &&
- !HasArrayFilter("startdate", "-3d") &&
- !HasArrayFilter("startdate", "-7d"));
-
- private string SetSearchFilter(string search, string key, params string[] values)
- {
- var filters = (search ?? string.Empty)
- .Split(',', StringSplitOptions.RemoveEmptyEntries)
- .Select(value => value.Trim())
- .Where(value => !string.IsNullOrWhiteSpace(value))
- .Where(value => !value.StartsWith($"{key}:", StringComparison.OrdinalIgnoreCase))
- .ToList();
-
- foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value)))
- {
- filters.Add($"{key}:{value}");
- }
-
- return filters.Count > 0 ? string.Join(',', filters) : string.Empty;
- }
-
- private string SetSearchFilter(string key, params string[] values) =>
- SetSearchFilter(Model.SearchTerm, key, values);
-}
-
-@* Custom Range Modal *@
-<div class="modal fade" id="customRangeModal" tabindex="-1" role="dialog" aria-labelledby="customRangeModalTitle" aria-hidden="true" data-bs-backdrop="static">
- <div class="modal-dialog modal-dialog-centered" role="document" style="max-width: 550px;">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title" id="customRangeModalTitle" text-translate="true">Filter transactions by Custom Range</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
- <vc:icon symbol="close" />
- </button>
- </div>
- <div class="modal-body">
- <div class="form-group row">
- <label for="dtpStartDate" class="col-sm-3 col-form-label" text-translate="true">Start Date</label>
- <div class="col-sm-9">
- <div class="input-group">
- <input id="dtpStartDate" class="form-control flatdtpicker" type="datetime-local"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
- placeholder="@StringLocalizer["Start Date"]" />
- <button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
- <vc:icon symbol="close" />
- </button>
- </div>
- </div>
- </div>
- <div class="form-group row">
- <label for="dtpEndDate" class="col-sm-3 col-form-label" text-translate="true">End Date</label>
- <div class="col-sm-9">
- <div class="input-group">
- <input id="dtpEndDate" class="form-control flatdtpicker" type="datetime-local"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
- placeholder="@StringLocalizer["End Date"]" />
- <button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
- <vc:icon symbol="close" />
- </button>
- </div>
- </div>
- </div>
- </div>
- <div class="modal-footer">
- <button id="btnCustomRangeDate" type="button" class="btn btn-primary" text-translate="true">Filter</button>
- </div>
- </div>
- </div>
-</div>
-
-<div id="TransactionsToolbar" class="d-flex flex-wrap gap-3 mb-4">
- <form id="WalletTransactionsSearch" class="d-flex flex-wrap align-items-center gap-3 mb-0" asp-action="WalletTransactions" asp-route-walletId="@walletId" method="get">
- <input id="Count" name="Count" type="hidden" value="@Model.Count" />
- <input id="TimezoneOffset" name="TimezoneOffset" type="hidden" value="@Model.TimezoneOffset" />
- <input name="SearchTerm" type="hidden" value="@Model.SearchTerm" />
- @if (!string.IsNullOrEmpty(labelFilter))
- {
- <input name="labelFilter" type="hidden" value="@labelFilter" />
- }
- <input id="SearchText" name="SearchText" class="form-control" value="@Model.SearchInputText" placeholder="@StringLocalizer["Search transactions..."]" aria-label="@StringLocalizer["Search transactions"]" />
- <div class="dropdown">
- <button id="DirectionOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
- @if (hasIncomingOnlyFilter)
- {
- <span text-translate="true">Incoming</span>
- }
- else if (hasOutgoingOnlyFilter)
- {
- <span text-translate="true">Outgoing</span>
- }
- else
- {
- <span text-translate="true">All Directions</span>
- }
- </button>
- <div class="dropdown-menu" aria-labelledby="DirectionOptionsToggle">
- <button type="button" class="dropdown-item @((!hasIncomingOnlyFilter && !hasOutgoingOnlyFilter) ? "custom-active" : string.Empty)" data-direction-filter="" text-translate="true">All Directions</button>
- <button type="button" class="dropdown-item @(hasIncomingOnlyFilter ? "custom-active" : string.Empty)" data-direction-filter="in" text-translate="true">Incoming</button>
- <button type="button" class="dropdown-item @(hasOutgoingOnlyFilter ? "custom-active" : string.Empty)" data-direction-filter="out" text-translate="true">Outgoing</button>
- </div>
- </div>
- @if (Model.Labels.Any())
- {
- <div class="dropdown">
- <button id="LabelOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
- @if (labelFilterCount > 1)
- {
- <span>@StringLocalizer["{0} Labels", labelFilterCount]</span>
- }
- else if (searchLabelFilters.Length == 1)
- {
- <span text-translate="true">Label:</span>
- <span>@searchLabelFilters[0]</span>
- }
- else if (!string.IsNullOrEmpty(labelFilter))
- {
- <span text-translate="true">Label:</span>
- <span>@labelFilter</span>
- }
- else if (hasNoLabelFilter)
- {
- <span text-translate="true">No Label</span>
- }
- else
- {
- <span text-translate="true">All Labels</span>
- }
- </button>
- @{
- var visibleLabels = initialLabels;
- }
- <ul class="dropdown-menu mt-1 py-0" id="LabelDropdownMenu" aria-labelledby="LabelOptionsToggle" style="min-width:280px">
- <li class="px-2 pt-2 pb-2@(sortedLabels.Count > maxVisibleLabels ? string.Empty : " d-none")" id="LabelSearchContainer">
- <div class="input-group border rounded">
- <span class="input-group-text border-0 text-muted px-2" style="background:transparent">
- <vc:icon symbol="actions-search" />
- </span>
- <input type="text" class="form-control border-0 shadow-none ps-0 bg-transparent" id="LabelSearch"
- placeholder="@StringLocalizer["Search labels…"]" autocomplete="off" aria-label="@StringLocalizer["Search labels…"]" />
- </div>
- </li>
- <li>
- <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("label"), "nolabel")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @(labelFilterCount == 0 ? "custom-active" : string.Empty)" text-translate="true">All Labels</a>
- </li>
- <li>
- <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("label"), "nolabel", "true")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @(hasNoLabelFilter ? "custom-active" : string.Empty)" text-translate="true">No Label</a>
- </li>
- <li><hr class="dropdown-divider"></li>
- <li id="LabelFilterResultsAnchor" class="d-none"></li>
- @foreach (var label in visibleLabels)
- {
- <li class="label-filter-item">
- <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("nolabel"), "label", label.Text)" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item transaction-label-text label-filter-link@((searchLabelFilters.Any(value => value.Equals(label.Text, StringComparison.OrdinalIgnoreCase)) || labelFilter == label.Text) ? " active" : string.Empty)" style="--btcpay-dropdown-link-active-bg:@label.Color;--btcpay-dropdown-link-active-color:@label.TextColor;">
- <span class="label-filter-text">@label.Text</span>
- <small class="label-filter-count@(label.UsageCount > 0 ? string.Empty : " d-none")">@label.UsageCount</small>
- </a>
- </li>
- }
- </ul>
- <template id="label-filter-item-template">
- <li class="label-filter-item">
- <a href="@Url.Action("WalletTransactions", new { walletId, labelFilter = string.Empty, count = Model.Count, searchText = Model.SearchText, searchTerm = SetSearchFilter(SetSearchFilter("nolabel"), "label", "LABEL_PLACEHOLDER"), timezoneOffset = Model.TimezoneOffset })" class="dropdown-item transaction-label-text label-filter-link">
- <span class="label-filter-text"></span>
- <small class="label-filter-count d-none"></small>
- </a>
- </li>
- </template>
- </div>
- }
- <div class="dropdown">
- <button id="DateOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
- @if (hasDateFilter)
- {
- if (HasArrayFilter("startdate", "-1d"))
- {
- <span text-translate="true">24 Hours</span>
- }
- else if (HasArrayFilter("startdate", "-3d"))
- {
- <span text-translate="true">3 Days</span>
- }
- else if (HasArrayFilter("startdate", "-7d"))
- {
- <span text-translate="true">7 Days</span>
- }
- else
- {
- <span text-translate="true">Custom</span>
- }
- }
- else
- {
- <span text-translate="true">All Time</span>
- }
- </button>
- <div class="dropdown-menu" aria-labelledby="DateOptionsToggle">
- <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="@labelFilter" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("startdate"), "enddate")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @((!HasArrayFilter("startdate") && !HasCustomDateFilter()) ? "custom-active" : string.Empty)" text-translate="true">All Time</a>
- <hr class="dropdown-divider" />
- <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="@labelFilter" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("enddate"), "startdate", "-1d")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @(HasArrayFilter("startdate", "-1d") ? "custom-active" : "")" text-translate="true">Last 24 hours</a>
- <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="@labelFilter" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("enddate"), "startdate", "-3d")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @(HasArrayFilter("startdate", "-3d") ? "custom-active" : "")" text-translate="true">Last 3 days</a>
- <a asp-action="WalletTransactions" asp-route-walletId="@walletId" asp-route-labelFilter="@labelFilter" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@SetSearchFilter(SetSearchFilter("enddate"), "startdate", "-7d")" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item @(HasArrayFilter("startdate", "-7d") ? "custom-active" : "")" text-translate="true">Last 7 days</a>
- <button type="button" class="dropdown-item @(HasCustomDateFilter() ? "custom-active" : "")" data-bs-toggle="modal" data-bs-target="#customRangeModal" text-translate="true">Custom Range</button>
- </div>
- </div>
- @if (Model.HasFilters || !string.IsNullOrEmpty(labelFilter))
- {
- <button id="clearAllFiltersBtn" type="button" class="btn btn-secondary" style="min-width: 7rem;" title="@StringLocalizer["Clear all filters"]">
- <span class="align-middle" text-translate="true">Clear All</span>
- </button>
- }
- </form>
-
<div class="d-flex flex-wrap align-items-center gap-3" id="Dropdowns">
<div class="dropdown d-flex flex-wrap gap-3" id="Export" permission="@WalletPolicies.CanViewWallet">
<a
@@ -631,17 +177,53 @@
<vc:icon symbol="nav-reporting" />
<span text-translate="true">Reporting</span>
</a>
- <button class="btn btn-secondary dropdown-toggle" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" text-translate="true">
- Export
- </button>
- <div class="dropdown-menu" aria-labelledby="ExportDropdownToggle">
- <a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="csv" asp-route-labelFilter="@labelFilter" asp-route-searchTerm="@Model.SearchTerm" asp-route-searchText="@Model.SearchText" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item export-link" target="_blank" id="ExportCSV" text-translate="true">CSV</a>
- <a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="json" asp-route-labelFilter="@labelFilter" asp-route-searchTerm="@Model.SearchTerm" asp-route-searchText="@Model.SearchText" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item export-link" target="_blank" id="ExportJSON" text-translate="true">JSON</a>
- <a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="bip329" asp-route-labelFilter="@labelFilter" asp-route-searchTerm="@Model.SearchTerm" asp-route-searchText="@Model.SearchText" asp-route-timezoneOffset="@Model.TimezoneOffset" class="dropdown-item export-link" target="_blank" id="ExportBIP329" text-translate="true">Wallet Labels (BIP-329)</a>
- </div>
+ <form asp-action="Export" asp-route-walletId="@walletId" target="_blank" method="get">
+ <button class="btn btn-secondary dropdown-toggle" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" text-translate="true">
+ Export
+ </button>
+ <div class="dropdown-menu" aria-labelledby="ExportDropdownToggle">
+ <input type="hidden" name="searchTerm" value="@Model.SearchTerm" />
+ <input type="hidden" name="searchText" value="@Model.SearchText" />
+ <button type="submit" name="format" value="csv" class="dropdown-item export-link" id="ExportCSV" text-translate="true">CSV</button>
+ <button type="submit" name="format" value="json" class="dropdown-item export-link" id="ExportJSON" text-translate="true">JSON</button>
+ <button type="submit" name="format" value="bip329" class="dropdown-item export-link" id="ExportBIP329" text-translate="true">Wallet Labels (BIP-329)</button>
+ </div>
+ </form>
</div>
</div>
</div>
+<partial name="_StatusMessage" />
+
+<form id="WalletTransactionsSearch" class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 col-xxl-8" asp-action="WalletTransactions" asp-route-walletId="@walletId" method="get">
+ <input id="Count" name="Count" type="hidden" value="@Model.Count" />
+ <vc:search-string-input search-string="Model.Search" placeholder="@StringLocalizer["Search transactions..."]"></vc:search-string-input>
+
+ <div class="dropdown" id="DirectionDropdown">
+ <button id="DirectionOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+ @if (hasIncomingOnlyFilter)
+ {
+ <span text-translate="true">Incoming</span>
+ }
+ else if (hasOutgoingOnlyFilter)
+ {
+ <span text-translate="true">Outgoing</span>
+ }
+ else
+ {
+ <span text-translate="true">All Directions</span>
+ }
+ </button>
+ <div class="dropdown-menu" aria-labelledby="DirectionOptionsToggle">
+ <button type="submit" name="FilterCommand" value="unset:direction" class="dropdown-item @((!hasIncomingOnlyFilter && !hasOutgoingOnlyFilter) ? "custom-active" : string.Empty)" text-translate="true">All Directions</button>
+ <button type="submit" name="FilterCommand" value="set:direction=in" class="dropdown-item @(hasIncomingOnlyFilter ? "custom-active" : string.Empty)" text-translate="true">Incoming</button>
+ <button type="submit" name="FilterCommand" value="set:direction=out" class="dropdown-item @(hasOutgoingOnlyFilter ? "custom-active" : string.Empty)" text-translate="true">Outgoing</button>
+ </div>
+ </div>
+ <vc:label-selector labels="Model.Labels" search="Model.Search" allow-no-label-filter="true"></vc:label-selector>
+ <vc:date-range-selector search="Model.Search" custom-range-title='@StringLocalizer["Filter transactions by Custom Range"].Value'></vc:date-range-selector>
+
+ <vc:clear-all-filters search="Model.Search"></vc:clear-all-filters>
+</form>
@if (Model.PendingTransactions?.Any() == true)
{
@@ -719,14 +301,7 @@
<th class="only-for-js mass-action-select-col" permission="@WalletPolicies.CanCreateWalletTransactions">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
- <th class="date-col">
- <div class="d-flex align-items-center gap-3">
- <span text-translate="true">Date</span>
- <button type="button" class="btn btn-link p-0 switch-time-format only-for-js" title="@StringLocalizer["Switch date format"]">
- <vc:icon symbol="time" />
- </button>
- </div>
- </th>
+ <vc:date-column></vc:date-column>
<th text-translate="true" style="min-width:125px">Label</th>
<th text-translate="true">Transaction</th>
<th text-translate="true" class="amount-col">Amount</th>
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIWallets/_WalletTransactionsList.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIWallets/_WalletTransactionsList.cshtml
index 4e42159..5bfa97d 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIWallets/_WalletTransactionsList.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIWallets/_WalletTransactionsList.cshtml
@@ -1,4 +1,3 @@
-@using BTCPayServer.Client
@using BTCPayServer.Services
@using BTCPayServer.Components.LabelManager
@model ListTransactionsViewModel
diff --git a/BTCPayServer/Plugins/Wallets/Views/ViewModels/ListTransactionsViewModel.cs b/BTCPayServer/Plugins/Wallets/Views/ViewModels/ListTransactionsViewModel.cs
index b9ab502..516d1ab 100644
--- a/BTCPayServer/Plugins/Wallets/Views/ViewModels/ListTransactionsViewModel.cs
+++ b/BTCPayServer/Plugins/Wallets/Views/ViewModels/ListTransactionsViewModel.cs
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
-using BTCPayServer;
+using BTCPayServer.Components.LabelSelector;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Services.Invoices;
@@ -28,16 +28,25 @@ namespace BTCPayServer.Plugins.Wallets.Views.ViewModels
public string InvoiceId { get; set; }
public TransactionHistoryLine HistoryLine { get; set; }
}
- public HashSet<(string Text, string Color, string TextColor, long UsageCount)> Labels { get; set; } = new();
- public List<(string Text, string Color, string TextColor, long UsageCount)> PopularLabels { get; set; } = new();
+ public HashSet<LabelSelectorItemViewModel> Labels { get; set; } = new();
public List<TransactionViewModel> Transactions { get; set; } = new();
public override int CurrentPageCount => Transactions.Count;
public string CryptoCode { get; set; }
public PendingTransaction[] PendingTransactions { get; set; }
public List<string> Rates { get; set; } = new();
- public string SearchText { get; set; }
- public string SearchInputText { get; set; }
- public SearchString Search { get; set; }
public bool HasFilters { get; set; }
+
+ protected override void AddUIFilters(SearchString search)
+ {
+ base.AddUIFilters(search);
+ search.UIFilterTypes.Add("direction");
+ LabelSelector.AddUIFilters(search);
+ }
+
+ protected override void RunFilterCommand(SearchString search)
+ {
+ base.RunFilterCommand(search);
+ LabelSelector.RunFilterCommand(search, FilterCommand);
+ }
}
}
diff --git a/BTCPayServer/SearchString.cs b/BTCPayServer/SearchString.cs
index 3ce659d..78f1bb9 100644
--- a/BTCPayServer/SearchString.cs
+++ b/BTCPayServer/SearchString.cs
@@ -1,3 +1,4 @@
+#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
@@ -5,160 +6,283 @@ using System.Linq;
namespace BTCPayServer
{
+ public enum SearchStringFormat
+ {
+ /// <summary>
+ /// Include all filters.
+ /// </summary>
+ All,
+ /// <summary>
+ /// Exclude filters that are configured via other UI elements (those defined in UIFilters). This is meant to be a
+ /// string that we show in an input textbox.
+ /// </summary>
+ ExceptUIFilters,
+ /// <summary>
+ /// Only include filters that are configured via other UI elements (those defined in UIFilters).
+ /// </summary>
+ OnlyUIFilters
+ }
+
public class SearchString
{
private const char FilterSeparator = ',';
private const char ValueSeparator = ':';
- private static readonly string[] StripFilters = ["status", "exceptionstatus", "unusual", "includearchived", "appid", "startdate", "enddate", "label", "nolabel", "direction"];
- private readonly string _originalString;
- private readonly int _timezoneOffset;
+ /// <summary>
+ /// The list of filters that shouldn't appear in the search input text
+ /// </summary>
+ public HashSet<string> UIFilterTypes = new HashSet<string>(["status", "timezone", "exceptionstatus", "unusual", "includearchived", "appid", "startdate", "enddate", "daterange"], StringComparer.OrdinalIgnoreCase);
+
+ public static SearchString Combine(string?[] str)
+ => new SearchString(string.Join(",", str.Where(s => !string.IsNullOrWhiteSpace(s))));
- public SearchString(string str, int timezoneOffset = 0)
+ public SearchString(string? str)
{
str ??= string.Empty;
str = str.Trim();
- _originalString = str;
- _timezoneOffset = timezoneOffset;
- TextSearch = _originalString;
var splitted = str.Split(new [] { FilterSeparator }, StringSplitOptions.RemoveEmptyEntries);
Filters
= splitted
.Select(t => t.Split(new [] { ValueSeparator }, 2, StringSplitOptions.RemoveEmptyEntries))
.Where(kv => kv.Length == 2)
- .Select(kv => new KeyValuePair<string, string>(UnifyKey(kv[0]), kv[1]))
+ .Select(kv => new KeyValuePair<string, string>(NormalizeKey(kv[0]), kv[1]))
.ToMultiValueDictionary(o => o.Key, o => o.Value);
- // combine raw search term and filters which don't have a special UI (e.g. orderid)
- var textFilters = Filters
- .Where(f => !StripFilters.Contains(f.Key))
- .Select(f => string.Join(FilterSeparator, f.Value.Select(v => $"{f.Key}{ValueSeparator}{v}"))).ToList();
- TextFilters = textFilters.Any() ? string.Join(FilterSeparator, textFilters) : null;
- TextSearch = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1)?.Trim();
+ TextSearch = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1)?.Trim() ?? "";
}
- public string TextSearch { get; private set; }
- public string TextFilters { get; private set; }
+ /// <summary>
+ /// The part of the search string that is free form text (not a filter)
+ /// </summary>
+ public string TextSearch { get; set; }
- public string TextCombined => string.Join(FilterSeparator, new []{ TextFilters, TextSearch }.Where(x => !string.IsNullOrEmpty(x)));
+ /// <summary>
+ /// The search string we should show in an input textbox.
+ /// Some filters are excluded from the string as they are configured via other UI elements
+ /// </summary>
+ [Obsolete("Use ToString(SearchStringFormat.OnlyUIFilters) instead")]
+ public string TextCombined => ToString(SearchStringFormat.OnlyUIFilters);
public MultiValueDictionary<string, string> Filters { get; }
- public override string ToString()
+ public override string ToString() => ToString(SearchStringFormat.All);
+
+ public string ToString(SearchStringFormat format)
{
- return _originalString;
+ var filters = Filters
+ .Where(kv => format switch
+ {
+ SearchStringFormat.All => true,
+ SearchStringFormat.ExceptUIFilters => !UIFilterTypes.Contains(kv.Key),
+ SearchStringFormat.OnlyUIFilters => UIFilterTypes.Contains(kv.Key),
+ _ => throw new ArgumentOutOfRangeException(nameof(format), format, null)
+ })
+ .Select(f => string.Join(FilterSeparator, f.Value.Select(v => $"{f.Key}{ValueSeparator}{v}"))).ToList();
+
+ if (format != SearchStringFormat.OnlyUIFilters)
+ filters.Add(TextSearch);
+ return string.Join(FilterSeparator, filters.Where(x => !string.IsNullOrEmpty(x)));
}
+
+ [Obsolete("Use ToString(SearchStringFormat.OnlyUIFilters) instead")]
+ public string WithoutSearchText() => ToString(SearchStringFormat.OnlyUIFilters);
+
+ [Obsolete("Use SetFilter(key, value, true) instead")]
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 s = Clone();
+ s.SetFilter(key, value, true);
+ return s.ToString();
+ }
- var boolFilter = GetFilterBool(key);
- if (boolFilter != null)
- {
- return Finalize(ToString().Replace(keyValue, string.Empty));
- }
+ public SearchString Clone() => new(ToString());
- 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));
- }
+ public string[]? GetFilterArray(string key)
+ {
+ key = NormalizeKey(key);
+ return Filters.TryGetValue(key, out var filter) ? filter.ToArray() : null;
+ }
- 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}"
- );
- }
+ public bool? GetFilterBool(string key)
+ {
+ key = NormalizeKey(key);
+ if (!Filters.TryGetValue(key, out var filter))
+ return null;
- return Finalize(ToString());
+ return bool.TryParse(filter.First(), out var r) ? r : null;
+ }
+ public string? GetFilterString(string key)
+ {
+ key = NormalizeKey(key);
+ if (!Filters.TryGetValue(key, out var filter))
+ return null;
+ return filter.First();
}
- public string WithoutSearchText()
+ public (DateTimeOffset? StartDate, DateTimeOffset? EndDate) GetDateRange()
+ => GetDateRange(null);
+ public (DateTimeOffset? StartDate, DateTimeOffset? EndDate) GetDateRange(TimeZoneInfo? defaultTimeZoneInfo)
{
- List<string> parts = new();
- foreach (var kv in Filters.Where(f => StripFilters.Contains(f.Key)))
+ DateTimeOffset? start;
+ DateTimeOffset? end;
+ if (Filters.TryGetValue("daterange", out var dateRange) && IsValidDateRange(dateRange.FirstOrDefault()))
{
- foreach (var value in kv.Value)
- {
- parts.Add($"{kv.Key}{ValueSeparator}{value}");
- }
+ start = GetDateRangeDate("startdate", dateRange.First(), defaultTimeZoneInfo);
+ end = GetDateRangeDate("enddate", dateRange.First(), defaultTimeZoneInfo);
+ return (start, end);
+ }
+ else
+ {
+ start = GetFilterDate("startdate", defaultTimeZoneInfo);
+ end = GetFilterDate("enddate", defaultTimeZoneInfo);
+ return (start, end);
}
- return string.Join(FilterSeparator, parts);
}
- public string[] GetFilterArray(string key)
+ TimeZoneInfo? GetTimeZoneInfo(TimeZoneInfo? defaultTimeZoneInfo)
{
- key = UnifyKey(key);
- return Filters.ContainsKey(key) ? Filters[key].ToArray() : null;
+ var tz = this.GetExplicitTimeZone();
+ if (tz is null || !TimeZones.TryGet(tz, out var tzInfo))
+ return defaultTimeZoneInfo;
+ return tzInfo;
}
- public bool? GetFilterBool(string key)
+ public DateTimeOffset? GetFilterDate(string key)
+ => GetFilterDate(key, null);
+ public DateTimeOffset? GetFilterDate(string key, TimeZoneInfo? defaultTimeZoneInfo)
{
- key = UnifyKey(key);
- if (!Filters.ContainsKey(key))
- return null;
-
- return bool.TryParse(Filters[key].First(), out var r) ? r : null;
- }
+ key = NormalizeKey(key);
+ var tz = GetTimeZoneInfo(defaultTimeZoneInfo);
- public DateTimeOffset? GetFilterDate(string key, int timezoneOffset)
- {
- key = UnifyKey(key);
- if (!Filters.ContainsKey(key))
+ if (!Filters.TryGetValue(key, out var filter))
return null;
- var val = Filters[key].First();
- 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);
- }
+ var val = filter.First();
+ var dateRangeDate = GetDateRangeDate(key, val, defaultTimeZoneInfo);
+ if (dateRangeDate is not null)
+ return dateRangeDate;
- // default parsing logic
- var success = DateTimeOffset.TryParse(val, null, DateTimeStyles.AssumeUniversal, out var r);
- if (success)
+ // Parsing the date
+ if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.None, out var localDateTime))
{
- r = r.AddMinutes(timezoneOffset);
- return r;
+ return localDateTime.Kind switch
+ {
+ DateTimeKind.Local => new DateTimeOffset(localDateTime, TimeZoneInfo.Local.GetUtcOffset(localDateTime)),
+ DateTimeKind.Utc => new DateTimeOffset(localDateTime, TimeSpan.Zero),
+ DateTimeKind.Unspecified when tz is not null => new DateTimeOffset(localDateTime, tz.GetUtcOffset(localDateTime)),
+ DateTimeKind.Unspecified when tz is null => null,
+ _ => throw new ArgumentOutOfRangeException()
+ };
}
-
return null;
}
- public bool ContainsFilter(string key)
+ private DateTimeOffset? GetDateRangeDate(string key, string val, TimeZoneInfo? defaultTimeZoneInfo)
{
- return Filters.ContainsKey(UnifyKey(key));
+ var utcNow = DateTimeOffset.UtcNow;
+ var rollingStart = val switch
+ {
+ "-24h" or "-1d" => utcNow.AddDays(-1),
+ "-3d" => utcNow.AddDays(-3),
+ "-7d" => utcNow.AddDays(-7),
+ _ => (DateTimeOffset?)null
+ };
+ if (rollingStart is not null)
+ return rollingStart;
+ var tz = GetTimeZoneInfo(defaultTimeZoneInfo);
+ if (tz is null)
+ return null;
+ var now = TimeZoneInfo.ConvertTime(utcNow, tz);
+ var today = now.Date;
+ var startOfThisWeek = today.AddDays(-(int)today.DayOfWeek);
+ var startOfThisMonth = new DateTime(today.Year, today.Month, 1);
+ var startOfThisQuarter = new DateTime(today.Year, ((today.Month - 1) / 3) * 3 + 1, 1);
+ var startOfThisYear = new DateTime(today.Year, 1, 1);
+
+ var localDate = (key, val) switch
+ {
+ ("startdate", "today") => today,
+ ("enddate", "today") => today.AddDays(1).AddTicks(-1),
+ ("startdate", "yesterday") => today.AddDays(-1),
+ ("enddate", "yesterday") => today.AddTicks(-1),
+ ("startdate", "thisweek") => startOfThisWeek,
+ ("startdate", "lastweek") => startOfThisWeek.AddDays(-7),
+ ("enddate", "lastweek") => startOfThisWeek.AddTicks(-1),
+ ("startdate", "thismonth") => startOfThisMonth,
+ ("startdate", "lastmonth") => startOfThisMonth.AddMonths(-1),
+ ("enddate", "lastmonth") => startOfThisMonth.AddTicks(-1),
+ ("startdate", "last30d") => today.AddDays(-29),
+ ("startdate", "thisquarter") => startOfThisQuarter,
+ ("startdate", "lastquarter") => startOfThisQuarter.AddMonths(-3),
+ ("enddate", "lastquarter") => startOfThisQuarter.AddTicks(-1),
+ ("startdate", "thisyear") => startOfThisYear,
+ ("startdate", "lastyear") => startOfThisYear.AddYears(-1),
+ ("enddate", "lastyear") => startOfThisYear.AddTicks(-1),
+ ("startdate", "yeartodate") => startOfThisYear,
+ _ => (DateTime?)null
+ };
+
+ if (localDate is null)
+ return null;
+ var local = DateTime.SpecifyKind(localDate.Value, DateTimeKind.Unspecified);
+ return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(local, tz), TimeSpan.Zero);
}
- private string UnifyKey(string key)
+ public bool ContainsFilter(string key) => Filters.ContainsKey(NormalizeKey(key));
+ public int CountArrayFilter(string type) =>
+ ContainsFilter(type) ? GetFilterArray(type)!.Length : 0;
+
+ public bool HasArrayFilter(string type, string? key = null) =>
+ ContainsFilter(type) && (key is null || GetFilterArray(type).Contains(key));
+
+ public bool HasBooleanFilter(string key) =>
+ ContainsFilter(key) && GetFilterBool(key) is true;
+
+ private string NormalizeKey(string key) => key.ToLowerInvariant().Trim();
+
+
+ public static bool IsValidDateRange(string? dateRange) =>
+ dateRange is "alltime" or "today" or "yesterday" or "thisweek" or "lastweek"
+ or "thismonth" or "lastmonth" or "last30d" or "thisquarter" or "lastquarter" or "thisyear" or "lastyear" or "yeartodate"
+ or "-1d" or "-24h" or "-3d" or "-7d";
+
+ public void SetFilter(string filter, string? value = null, bool toggle = false, bool multi = false)
{
- return key.ToLowerInvariant().Trim();
+ filter = NormalizeKey(filter);
+ if (!toggle)
+ {
+ if (!multi)
+ Filters.Remove(filter);
+ if (value is not null)
+ {
+ Filters.Remove(filter, value);
+ Filters.Add(filter, value);
+ }
+ }
+ else
+ {
+ if (!Filters.ContainsKey(filter) || value is null)
+ SetFilter(filter, value);
+ else if (Filters[filter].Contains(value))
+ {
+ Filters.Remove(filter, value);
+ }
+ else
+ {
+ if (!multi)
+ Filters.Remove(filter);
+ Filters.Add(filter, value);
+ }
+ }
}
- private static string Finalize(string str)
+ public void SetDateRange(string? dateRange = null, bool toggle = false)
{
- var value = str.Trim().TrimStart(FilterSeparator).TrimEnd(FilterSeparator);
- return string.IsNullOrEmpty(value) ? " " : value;
+ Filters.Remove("startdate");
+ Filters.Remove("enddate");
+ SetFilter("daterange", dateRange, toggle);
}
+
+ public string? GetExplicitTimeZone() => GetFilterString("timezone");
}
}
diff --git a/BTCPayServer/Security/SetContextFilter.cs b/BTCPayServer/Security/SetContextFilter.cs
index 3d7a4c3..eea9cf8 100644
--- a/BTCPayServer/Security/SetContextFilter.cs
+++ b/BTCPayServer/Security/SetContextFilter.cs
@@ -4,10 +4,12 @@ using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.HostedServices;
+using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace BTCPayServer.Security;
diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs
index a6feeee..9214c26 100644
--- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs
+++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs
@@ -1064,8 +1064,9 @@ retry:
public bool IncludeRefunds { get; set; }
public bool OrderByDesc { get; set; } = true;
- public void FillFromSearchText(SearchString fs, int timezoneOffset)
+ public void FillFromSearchText(SearchString fs)
{
+ var p = fs.GetDateRange(TimeZoneInfo.Utc);
TextSearch = fs.TextSearch;
Unusual = fs.GetFilterBool("unusual");
IncludeArchived = fs.GetFilterBool("includearchived") ?? false;
@@ -1074,8 +1075,8 @@ retry:
StoreId = fs.GetFilterArray("storeid");
ItemCode = fs.GetFilterArray("itemcode");
OrderId = fs.GetFilterArray("orderid");
- StartDate = fs.GetFilterDate("startdate", timezoneOffset);
- EndDate = fs.GetFilterDate("enddate", timezoneOffset);
+ StartDate = p.StartDate;
+ EndDate = p.EndDate;
}
}
diff --git a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs
index 3a80696..8fe4ace 100644
--- a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs
+++ b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs
@@ -176,17 +176,19 @@ namespace BTCPayServer.Services.PaymentRequests
}
}
- if (!string.IsNullOrEmpty(query.LabelFilter))
+ if (query.LabelFilter is not null)
{
if (string.IsNullOrEmpty(query.StoreId))
throw new InvalidOperationException("PaymentRequestQuery.StoreId should be specified for label filtering");
+ var labels = query.LabelFilter;
queryable = queryable.Where(pr =>
context.StoreLabelLinks.Any(l =>
l.StoreId == query.StoreId &&
l.ObjectId == pr.Id &&
l.StoreLabel.Type == WalletObjectData.Types.PaymentRequest &&
- l.StoreLabel.Text == query.LabelFilter.Trim()));
+ // ReSharper disable once CSharp14OverloadResolutionWithSpanBreakingChange
+ labels.Contains(l.StoreLabel.Text)));
}
queryable = queryable.Include(data => data.StoreData);
@@ -274,6 +276,6 @@ namespace BTCPayServer.Services.PaymentRequests
public string SearchText { get; set; }
public DateTimeOffset? StartDate { get; set; }
public DateTimeOffset? EndDate { get; set; }
- public string LabelFilter { get; set; }
+ public string[] LabelFilter { get; set; }
}
}
diff --git a/BTCPayServer/Services/PoliciesSettings.cs b/BTCPayServer/Services/PoliciesSettings.cs
index 937a2d2..39f702a 100644
--- a/BTCPayServer/Services/PoliciesSettings.cs
+++ b/BTCPayServer/Services/PoliciesSettings.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
diff --git a/BTCPayServer/TimeZones.cs b/BTCPayServer/TimeZones.cs
new file mode 100644
index 0000000..f090c65
--- /dev/null
+++ b/BTCPayServer/TimeZones.cs
@@ -0,0 +1,48 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+namespace BTCPayServer;
+
+public static class TimeZones
+{
+ private static readonly Dictionary<string, string> abbreviations;
+ private static readonly Dictionary<string, TimeZoneInfo> zones;
+
+ static TimeZones()
+ {
+ zones = TimeZoneInfo.GetSystemTimeZones().ToDictionary(t => t.Id, t => t, StringComparer.InvariantCultureIgnoreCase);
+ abbreviations = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase)
+ {
+ ["UTC"] = "Etc/UTC",
+ ["GMT"] = "Etc/UTC",
+ ["JST"] = "Asia/Tokyo",
+ ["KST"] = "Asia/Seoul",
+ ["HKT"] = "Asia/Hong_Kong",
+ ["SGT"] = "Asia/Singapore",
+ ["PHT"] = "Asia/Manila",
+ ["CET"] = "Europe/Paris",
+ ["CEST"] = "Europe/Paris",
+ ["EST"] = "America/New_York",
+ ["EDT"] = "America/New_York",
+ ["CDT"] = "America/Chicago",
+ ["MDT"] = "America/Denver",
+ ["PST"] = "America/Los_Angeles",
+ ["PDT"] = "America/Los_Angeles",
+ ["AKST"] = "America/Anchorage",
+ ["AKDT"] = "America/Anchorage",
+ ["HST"] = "Pacific/Honolulu"
+ };
+ }
+
+ public static TimeZoneInfo? TryGet(string id)
+ => TryGet(id, out var zone) ? zone : null;
+
+ public static bool TryGet(string id, [MaybeNullWhen(false)] out TimeZoneInfo zone)
+ {
+ abbreviations.TryGetValue(id, out var fullName);
+ return zones.TryGetValue(fullName ?? id, out zone);
+ }
+}
diff --git a/BTCPayServer/ViewDataDictionaryExtensions.cs b/BTCPayServer/ViewDataDictionaryExtensions.cs
new file mode 100644
index 0000000..8f2a43a
--- /dev/null
+++ b/BTCPayServer/ViewDataDictionaryExtensions.cs
@@ -0,0 +1,28 @@
+#nullable enable
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+
+namespace BTCPayServer;
+
+public static class ViewDataDictionaryExtensions
+{
+ public static string? GetPageTimeZone(this ViewDataDictionary viewData)
+ => viewData["timezone"] as string;
+ /// <summary>
+ /// Set the timezone of the current page. If null, the timezone of the browser will be used.
+ /// </summary>
+ /// <param name="viewData"></param>
+ /// <param name="timezone"></param>
+ public static void SetPageTimeZone(this ViewDataDictionary viewData, string? timezone)
+ => viewData["timezone"] = timezone;
+
+ /// <summary>
+ /// Set the timezone of the current page from the SearchString if it includes a `timezone=` filter.
+ /// </summary>
+ /// <param name="viewData"></param>
+ /// <param name="searchString"></param>
+ public static void SetPageTimeZone(this ViewDataDictionary viewData, SearchString? searchString)
+ {
+ if (searchString?.GetExplicitTimeZone() is string timezone)
+ viewData["timezone"] = timezone;
+ }
+}
diff --git a/BTCPayServer/Views/Shared/LayoutFoot.cshtml b/BTCPayServer/Views/Shared/LayoutFoot.cshtml
index 31b39df..da06a88 100644
--- a/BTCPayServer/Views/Shared/LayoutFoot.cshtml
+++ b/BTCPayServer/Views/Shared/LayoutFoot.cshtml
@@ -8,4 +8,5 @@
{
<script src="~/_framework/blazor.server.js" autostart="false" asp-append-version="true"></script>
}
+<script src="~/main/datetime.js" asp-append-version="true"></script>
<script src="~/main/site.js" asp-append-version="true"></script>
diff --git a/BTCPayServer/Views/Shared/_Layout.cshtml b/BTCPayServer/Views/Shared/_Layout.cshtml
index 2f28a0b..b6ee31d 100644
--- a/BTCPayServer/Views/Shared/_Layout.cshtml
+++ b/BTCPayServer/Views/Shared/_Layout.cshtml
@@ -8,6 +8,7 @@
var notificationDisabled = user?.DisabledNotifications == "all";
var expectedScheme = _context.HttpContext.Request.Scheme;
var expectedHost = _context.HttpContext.Request.Host.ToString().ToLower();
+ var store = _context.HttpContext.GetStoreDataOrNull();
}
<!DOCTYPE html>
@@ -15,6 +16,7 @@
<head>
<partial name="LayoutHead" />
@await RenderSectionAsync("PageHeadContent", false)
+ <vc:declare-date-formatter-options></vc:declare-date-formatter-options>
</head>
<body class="d-flex flex-column flex-lg-row min-vh-100">
<header id="mainMenu" class="btcpay-header d-flex flex-column d-print-none" v-pre>
diff --git a/BTCPayServer/Views/UIInvoice/Invoice.cshtml b/BTCPayServer/Views/UIInvoice/Invoice.cshtml
index 4298216..65515d1 100644
--- a/BTCPayServer/Views/UIInvoice/Invoice.cshtml
+++ b/BTCPayServer/Views/UIInvoice/Invoice.cshtml
@@ -134,7 +134,6 @@
return;
}
- console.log({ refundOption, isInvalid, amount, currency })
const reduceByAmount = (amount * (percentage / 100));
const refundAmount = (amount - reduceByAmount).toFixed(divisibility);
$result.innerText = `= ${refundAmount} ${currency} refund`;
@@ -611,7 +610,7 @@
<table class="table table-hover mt-3 mb-4">
<thead>
<tr>
- <th>Date</th>
+ <vc:date-column></vc:date-column>
<th>Message</th>
</tr>
</thead>
@@ -620,7 +619,7 @@
{
var cssClass = string.IsNullOrEmpty(evt.GetCssClass()) ? null : $"text-{evt.GetCssClass()}";
<tr>
- <td class="@cssClass">@evt.Timestamp.ToBrowserDate("o", "short", "medium")</td>
+ <td class="@cssClass">@evt.Timestamp.ToBrowserDate()</td>
<td class="@cssClass">@evt.Message</td>
</tr>
}
diff --git a/BTCPayServer/Views/UIInvoice/InvoiceReceipt.cshtml b/BTCPayServer/Views/UIInvoice/InvoiceReceipt.cshtml
index 77a81f9..ad42ea1 100644
--- a/BTCPayServer/Views/UIInvoice/InvoiceReceipt.cshtml
+++ b/BTCPayServer/Views/UIInvoice/InvoiceReceipt.cshtml
@@ -37,6 +37,7 @@
#AdditionalData td > table:last-child, #CartData td > table:last-child { margin-bottom: 0 !important; }
#AdditionalData table > tbody > tr:first-child > td > h4, #CartData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; }
</style>
+ <vc:declare-date-formatter-options></vc:declare-date-formatter-options>
</head>
<body class="min-vh-100">
<div id="InvoiceReceipt" class="public-page-wrap">
diff --git a/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml b/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml
index 1d2abcf..b8459d0 100644
--- a/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml
+++ b/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml
@@ -69,6 +69,7 @@
margin-bottom: 0px;
}
</style>
+ <vc:declare-date-formatter-options></vc:declare-date-formatter-options>
</head>
<body class="m-0 p-0 bg-white">
diff --git a/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml b/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml
index 18253c8..5d19a92 100644
--- a/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml
+++ b/BTCPayServer/Views/UIInvoice/ListInvoices.cshtml
@@ -5,24 +5,8 @@
@model InvoicesModel
@{
ViewData.SetLayoutModel(new("Invoices", StringLocalizer["Invoices"]));
- var statusFilterCount = CountArrayFilter("status") + CountArrayFilter("exceptionstatus") + (HasBooleanFilter("includearchived") ? 1 : 0) + (HasBooleanFilter("unusual") ? 1 : 0);
- var hasDateFilter = HasArrayFilter("startdate") || HasArrayFilter("enddate");
- var appFilterCount = Model.Apps.Count(app => HasArrayFilter("appid", app.Id));
-}
-
-@functions
-{
- private int CountArrayFilter(string type) =>
- Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0;
-
- private bool HasArrayFilter(string type, string key = null) =>
- Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key));
-
- private bool HasBooleanFilter(string key) =>
- Model.Search.ContainsFilter(key) && Model.Search.GetFilterBool(key) is true;
-
- private bool HasCustomDateFilter() =>
- Model.Search.ContainsFilter("startdate") && Model.Search.ContainsFilter("enddate");
+ var statusFilterCount = Model.Search.CountArrayFilter("status") + Model.Search.CountArrayFilter("exceptionstatus") + (Model.Search.HasBooleanFilter("includearchived") ? 1 : 0) + (Model.Search.HasBooleanFilter("unusual") ? 1 : 0);
+ var appFilterCount = Model.Apps.Count(app => Model.Search.HasArrayFilter("appid", app.Id));
}
@section PageHeadContent
@@ -56,50 +40,14 @@
@*Script must be async to ensure proper page loading*@
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
- @* Custom Range Modal *@
<script>
var invoicesUrl = @Safe.Json(Url.Action(nameof(UIInvoiceController.ListInvoices), new { storeId = Model.StoreId }));
- const timezoneOffset = new Date().getTimezoneOffset();
delegate('click', '.showInvoice', e => {
e.preventDefault();
const { invoiceId } = e.target.dataset;
btcpay.appendInvoiceFrame(invoiceId);
})
-
- $('#btnCustomRangeDate').on('click', function (sender) {
- var filterString = "";
-
- var dtpStartDate = $("#dtpStartDate").val();
- if (dtpStartDate !== null && dtpStartDate !== "") {
- filterString = "startdate%3A" + dtpStartDate;
- }
-
- var dtpEndDate = $("#dtpEndDate").val();
- if (dtpEndDate !== null && dtpEndDate !== "") {
- if (filterString !== "") {
- filterString += ",";
- }
- filterString += "enddate%3A" + dtpEndDate;
- }
-
- if (filterString !== "") {
- var redirectUri = invoicesUrl + "?Count=" + $("#Count").val() +
- "&timezoneoffset=" + $("#TimezoneOffset").val() +
- "&SearchTerm=" + filterString;
-
- window.location.href = redirectUri;
- } else {
- $("#dtpStartDate").next().trigger("focus");
- }
- })
-
- function getDateStringWithOffset(hoursDiff) {
- var datenow = new Date();
- var newDate = new Date(datenow.getTime() - (hoursDiff * 60 * 60 * 1000));
- var str = newDate.toLocaleDateString() + " " + newDate.toLocaleTimeString();
- return str;
- }
</script>
}
@@ -156,56 +104,10 @@
<partial name="_StatusMessage" />
-@* Custom Range Modal *@
-<div class="modal fade" id="customRangeModal" tabindex="-1" role="dialog" aria-labelledby="customRangeModalTitle" aria-hidden="true" data-bs-backdrop="static">
- <div class="modal-dialog modal-dialog-centered" role="document" style="max-width: 550px;">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title" id="customRangeModalTitle" text-translate="true">Filter invoices by Custom Range</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
- <vc:icon symbol="close" />
- </button>
- </div>
- <div class="modal-body">
- <div class="form-group row">
- <label for="dtpStartDate" class="col-sm-3 col-form-label" text-translate="true">Start Date</label>
- <div class="col-sm-9">
- <div class="input-group">
- <input id="dtpStartDate" class="form-control flatdtpicker" type="datetime-local"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
- placeholder="@StringLocalizer["Start Date"]" />
- <button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
- <vc:icon symbol="close" />
- </button>
- </div>
- </div>
- </div>
- <div class="form-group row">
- <label class="col-sm-3 col-form-label" text-translate="true">End Date</label>
- <div class="col-sm-9">
- <div class="input-group">
- <input id="dtpEndDate" class="form-control flatdtpicker" type="datetime-local"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
- placeholder="@StringLocalizer["End Date"]" />
- <button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
- <vc:icon symbol="close" />
- </button>
- </div>
- </div>
- </div>
- </div>
- <div class="modal-footer">
- <button id="btnCustomRangeDate" type="button" class="btn btn-primary" text-translate="true">Filter</button>
- </div>
- </div>
- </div>
-</div>
-
<form class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 col-xxl-8" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" method="get">
<input asp-for="Count" type="hidden" />
- <input asp-for="TimezoneOffset" type="hidden" />
- <input asp-for="SearchTerm" type="hidden" value="@Model.Search.WithoutSearchText()"/>
- <input asp-for="SearchText" class="form-control" placeholder="@StringLocalizer["Search…"]" />
+
+ <vc:search-string-input search-string="Model.Search"></vc:search-string-input>
<div class="dropdown">
<button id="StatusOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (statusFilterCount > 0)
@@ -218,17 +120,17 @@
}
</button>
<div class="dropdown-menu" aria-labelledby="StatusOptionsToggle">
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "settled")" class="dropdown-item @(HasArrayFilter("status", "settled") ? "custom-active" : "")" text-translate="true">Settled</a>
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "processing")" class="dropdown-item @(HasArrayFilter("status", "processing") ? "custom-active" : "")" text-translate="true">Processing</a>
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "expired")" class="dropdown-item @(HasArrayFilter("status", "expired") ? "custom-active" : "")" text-translate="true">Expired</a>
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "invalid")" class="dropdown-item @(HasArrayFilter("status", "invalid") ? "custom-active" : "")" text-translate="true">Invalid</a>
+ <button type="submit" name="FilterCommand" value="set-multi:status=settled" class="dropdown-item @(Model.Search.HasArrayFilter("status", "settled") ? "custom-active" : "")" text-translate="true">Settled</button>
+ <button type="submit" name="FilterCommand" value="set-multi:status=processing" class="dropdown-item @(Model.Search.HasArrayFilter("status", "processing") ? "custom-active" : "")" text-translate="true">Processing</button>
+ <button type="submit" name="FilterCommand" value="set-multi:status=expired" class="dropdown-item @(Model.Search.HasArrayFilter("status", "expired") ? "custom-active" : "")" text-translate="true">Expired</button>
+ <button type="submit" name="FilterCommand" value="set-multi:status=invalid" class="dropdown-item @(Model.Search.HasArrayFilter("status", "invalid") ? "custom-active" : "")" text-translate="true">Invalid</button>
<hr class="dropdown-divider">
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("exceptionstatus", "paidLate")" class="dropdown-item @(HasArrayFilter("exceptionstatus", "paidLate") ? "custom-active" : "")" text-translate="true">Settled Late</a>
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("exceptionstatus", "paidPartial")" class="dropdown-item @(HasArrayFilter("exceptionstatus", "paidPartial") ? "custom-active" : "")" text-translate="true">Settled Partial</a>
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("exceptionstatus", "paidOver")" class="dropdown-item @(HasArrayFilter("exceptionstatus", "paidOver") ? "custom-active" : "")" text-translate="true">Settled Over</a>
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("unusual", "true")" class="dropdown-item @(HasBooleanFilter("unusual") ? "custom-active" : "")" text-translate="true">Unusual</a>
+ <button type="submit" name="FilterCommand" value="set-multi:exceptionstatus=paidLate" class="dropdown-item @(Model.Search.HasArrayFilter("exceptionstatus", "paidLate") ? "custom-active" : "")" text-translate="true">Settled Late</button>
+ <button type="submit" name="FilterCommand" value="set-multi:exceptionstatus=paidPartial" class="dropdown-item @(Model.Search.HasArrayFilter("exceptionstatus", "paidPartial") ? "custom-active" : "")" text-translate="true">Settled Partial</button>
+ <button type="submit" name="FilterCommand" value="set-multi:exceptionstatus=paidOver" class="dropdown-item @(Model.Search.HasArrayFilter("exceptionstatus", "paidOver") ? "custom-active" : "")" text-translate="true">Settled Over</button>
+ <button type="submit" name="FilterCommand" value="set-multi:unusual=true" class="dropdown-item @(Model.Search.HasBooleanFilter("unusual") ? "custom-active" : "")" text-translate="true">Unusual</button>
<hr class="dropdown-divider">
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("includearchived", "true")" class="dropdown-item @(HasBooleanFilter("includearchived") ? "custom-active" : "")" id="StatusOptionsIncludeArchived" text-translate="true">Include archived</a>
+ <button type="submit" name="FilterCommand" value="set:includearchived=true" class="dropdown-item @(Model.Search.HasBooleanFilter("includearchived") ? "custom-active" : "")" id="StatusOptionsIncludeArchived" text-translate="true">Include archived</button>
</div>
</div>
@if (Model.Apps.Any())
@@ -247,73 +149,15 @@
<div class="dropdown-menu" aria-labelledby="AppOptionsToggle">
@foreach (var app in Model.Apps)
{
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("appid", app.Id)" class="dropdown-item @(HasArrayFilter("appid", app.Id) ? "custom-active" : "")">@app.AppName</a>
+ <button type="submit" name="FilterCommand" value="set-multi:appid=@app.Id" class="dropdown-item @(Model.Search.HasArrayFilter("appid", app.Id) ? "custom-active" : "")">@app.AppName</button>
}
</div>
</div>
}
- <div class="dropdown">
- <button id="DateOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
- @if (hasDateFilter)
- {
- if (HasArrayFilter("startdate", "-1d"))
- {
- <span text-translate="true">24 Hours</span>
- }
- else if (HasArrayFilter("startdate", "-3d"))
- {
- <span text-translate="true">3 Days</span>
- }
- else if (HasArrayFilter("startdate", "-7d"))
- {
- <span text-translate="true">7 Days</span>
- }
- else
- {
- <span text-translate="true">Custom</span>
- }
- }
- else
- {
- <span text-translate="true">All Time</span>
- }
- </button>
- <div class="dropdown-menu" aria-labelledby="DateOptionsToggle">
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("startdate", "-1d")" class="dropdown-item @(HasArrayFilter("startdate", "-1d") ? "custom-active" : "")" text-translate="true">Last 24 hours</a>
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("startdate", "-3d")" class="dropdown-item @(HasArrayFilter("startdate", "-3d") ? "custom-active" : "")" text-translate="true">Last 3 days</a>
- <a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("startdate", "-7d")" class="dropdown-item @(HasArrayFilter("startdate", "-7d") ? "custom-active" : "")" text-translate="true">Last 7 days</a>
- <button type="button" class="dropdown-item @(HasCustomDateFilter() ? "custom-active" : "")" data-bs-toggle="modal" data-bs-target="#customRangeModal" text-translate="true">Custom Range</button>
- </div>
- </div>
- @if (statusFilterCount > 0 || appFilterCount > 0 || hasDateFilter || !string.IsNullOrEmpty(Model.SearchText))
- {
- <button id="clearAllFiltersBtn" type="button" class="btn btn-secondary" style="min-width: 7rem;" title="@StringLocalizer["Clear all filters"]">
- <span class="align-middle" text-translate="true">Clear All</span>
- </button>
- }
+ <vc:date-range-selector search="Model.Search" custom-range-title='@StringLocalizer["Filter invoices by Custom Range"].Value'></vc:date-range-selector>
+ <vc:clear-all-filters search="Model.Search"></vc:clear-all-filters>
</form>
-<script>
- document.addEventListener('DOMContentLoaded', function () {
- const clearBtn = document.getElementById('clearAllFiltersBtn');
- if (clearBtn) {
- clearBtn.addEventListener('click', function () {
- const form = clearBtn.closest('form');
- if (!form) return;
- // Reset search text
- const searchText = form.querySelector('[name="SearchText"]');
- if (searchText) searchText.value = '';
- // Reset SearchTerm (filters)
- const searchTerm = form.querySelector('[name="SearchTerm"]');
- if (searchTerm) searchTerm.value = '';
- // Optionally reset other hidden fields if needed
- // Submit the form
- form.submit();
- });
- }
- });
-</script>
-
@if (Model.Invoices.Any())
{
<form method="post" asp-action="MassAction" asp-route-storeId="@Model.StoreId">
@@ -325,14 +169,7 @@
<th class="mass-action-select-col only-for-js">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
- <th class="date-col">
- <div class="d-flex align-items-center gap-3">
- <span text-translate="true">Date</span>
- <button type="button" class="btn btn-link p-0 switch-time-format only-for-js" title="@StringLocalizer["Switch date format"]">
- <vc:icon symbol="time" />
- </button>
- </div>
- </th>
+ <vc:date-column></vc:date-column>
<th text-translate="true" class="text-nowrap">Invoice Id</th>
<th text-translate="true" class="text-nowrap">Order Id</th>
<th text-translate="true">Status</th>
@@ -356,7 +193,7 @@
<vc:icon symbol="actions-archive" />
<span text-translate="true">Archive</span>
</button>
- @if (HasBooleanFilter("includearchived"))
+ @if (Model.Search.HasBooleanFilter("includearchived"))
{
<button type="submit" name="command" value="unarchive" id="UnarchiveSelected" class="btn btn-link">
<vc:icon symbol="actions-archive" />
diff --git a/BTCPayServer/Views/UINotifications/Index.cshtml b/BTCPayServer/Views/UINotifications/Index.cshtml
index ee2723b..5095831 100644
--- a/BTCPayServer/Views/UINotifications/Index.cshtml
+++ b/BTCPayServer/Views/UINotifications/Index.cshtml
@@ -1,18 +1,9 @@
@model BTCPayServer.Models.NotificationViewModels.NotificationIndexViewModel
@{
ViewData["Title"] = ViewLocalizer["Notifications"];
- string status = ViewBag.Status;
- var statusFilterCount = CountArrayFilter("type");
- var storesFilterCount = CountArrayFilter("storeid");
-}
-
-@functions
-{
- private int CountArrayFilter(string type) =>
- Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0;
-
- private bool HasArrayFilter(string type, string key = null) =>
- Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key));
+ var showAll = Model.Search.GetFilterBool("all") is true;
+ var statusFilterCount = Model.Search.CountArrayFilter("type");
+ var storesFilterCount = Model.Search.CountArrayFilter("storeid");
}
@section PageHeadContent
@@ -38,34 +29,23 @@
</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">
+<form class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 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="@StringLocalizer["Search…"]" />
- </div>
+ <vc:search-string-input search-string="Model.Search"></vc:search-string-input>
- <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"
+ <div class="btn-group" role="group" aria-label="View Notification">
+ <button type="submit" name="FilterCommand" value="set:all=true" class="btn @(showAll ? "btn-primary" : "btn-outline-secondary")"
text-translate="true">
All
- </a>
- <a class="btn @((status == "Unread") ? "btn-primary" : "btn-outline-secondary")"
- asp-controller="UINotifications"
- asp-action="Index"
- asp-route-Status="Unread"
+ </button>
+ <button type="submit" name="FilterCommand" value="unset:all" class="btn @(!showAll ? "btn-primary" : "btn-outline-secondary")"
text-translate="true">
Unread
- </a>
+ </button>
</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">
+ <div class="dropdown">
+ <button id="StatusOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (statusFilterCount > 0)
{
<span>@statusFilterCount Type</span>
@@ -76,16 +56,18 @@
}
</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" : "")" text-translate="true">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" : "")" text-translate="true">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" : "")" text-translate="true">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" : "")" text-translate="true">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" : "")" text-translate="true">User Updates</a>
+ <button type="submit" name="FilterCommand" value="unset:type" class="dropdown-item @(!Model.Search.HasArrayFilter("type") ? "custom-active" : "")" text-translate="true">All Type</button>
+ <hr class="dropdown-divider">
+ <button type="submit" name="FilterCommand" value="set-multi:type=invoicestate" class="dropdown-item @(Model.Search.HasArrayFilter("type", "invoicestate") ? "custom-active" : "")" text-translate="true">Invoice</button>
+ <button type="submit" name="FilterCommand" value="set-multi:type=payout" class="dropdown-item @(Model.Search.HasArrayFilter("type", "payout") ? "custom-active" : "")" text-translate="true">Payouts</button>
+ <button type="submit" name="FilterCommand" value="set-multi:type=newversion" class="dropdown-item @(Model.Search.HasArrayFilter("type", "newversion") ? "custom-active" : "")" text-translate="true">New Version</button>
+ <button type="submit" name="FilterCommand" value="set-multi:type=pluginupdate" class="dropdown-item @(Model.Search.HasArrayFilter("type", "pluginupdate") ? "custom-active" : "")" text-translate="true">Plugin Updates</button>
+ <button type="submit" name="FilterCommand" value="set-multi:type=userupdate" class="dropdown-item @(Model.Search.HasArrayFilter("type", "userupdate") ? "custom-active" : "")" text-translate="true">User Updates</button>
</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">
+ <div class="dropdown">
+ <button id="StoresOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (storesFilterCount > 0)
{
<span>@(storesFilterCount == 1 ? StringLocalizer["{0} Store", storesFilterCount] : StringLocalizer["{0} Stores", storesFilterCount])</span>
@@ -98,13 +80,10 @@
<div class="dropdown-menu" aria-labelledby="StoresOptionsToggle">
@foreach (var store in Model.StoreFilterOptions)
{
- <a asp-action="Index"
- asp-route-count="@Model.Count"
- asp-route-status="@Model.Status"
- asp-route-searchTerm="@Model.Search.Toggle("storeid", store.Value)"
+ <button type="submit" name="FilterCommand" value="set:storeid=@store.Value"
class="dropdown-item @(store.Selected ? "custom-active" : "")">
@store.Text
- </a>
+ </button>
}
</div>
</div>
@@ -123,14 +102,7 @@
<input name="selectedItems" type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th text-translate="true">Message</th>
- <th class="date-col">
- <div class="d-flex align-items-center gap-3">
- <span text-translate="true">Date</span>
- <button type="button" class="btn btn-link p-0 switch-time-format only-for-js" title="@StringLocalizer["Switch date format"]">
- <vc:icon symbol="time" />
- </button>
- </div>
- </th>
+ <vc:date-column></vc:date-column>
<th class="text-end text-success" text-translate="true">Actions</th>
</tr>
</thead>
@@ -142,7 +114,7 @@
<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">
- @if (Model.Status == "Unread")
+ @if (!showAll)
{
<button type="submit" name="command" value="mark-seen" class="btn btn-link gap-1">
<vc:icon symbol="actions-show" />
@@ -224,13 +196,21 @@ else
@section PageFootContent {
<script type="text/javascript">
- delegate('click', '.btn-toggle-seen', e => {
+ delegate('click', '.btn-toggle-seen', async e => {
const row = $(e.target).parents(".notification-row").toggleClass("loading");
const guid = row.data("guid");
const url = "@Url.Action("FlipRead", "UINotifications", new { id = "placeholder" })".replace("placeholder", guid);
- $.post(url, function (data) {
- row.toggleClass("seen loading");
+ const tokenInput = document.querySelector('input[name="__RequestVerificationToken"]');
+ const headers = { 'Content-Type': 'application/json' };
+ headers['RequestVerificationToken'] = tokenInput.value;
+
+ await fetch(url, {
+ method: 'POST',
+ headers,
+ body: '{}'
});
+
+ row.toggleClass("seen loading");
return false;
})
</script>
diff --git a/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml b/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
index d40d6b8..da54d89 100644
--- a/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
+++ b/BTCPayServer/Views/UIPaymentRequest/GetPaymentRequests.cshtml
@@ -7,17 +7,7 @@
Layout = "_Layout";
ViewData.SetLayoutModel(new("PaymentRequests", StringLocalizer["Payment Requests"]));
var storeId = Context.GetStoreData().Id;
- var statusFilterCount = CountArrayFilter("status") + (HasBooleanFilter("includearchived") ? 1 : 0);
- var hasDateFilter = HasArrayFilter("startdate") || HasArrayFilter("enddate");
- var hasTextSearch = !string.IsNullOrEmpty(Model.SearchText) || !string.IsNullOrEmpty(Model.Search?.TextSearch);
- var hasAnyFilter =
- statusFilterCount > 0 ||
- hasDateFilter ||
- hasTextSearch ||
- !string.IsNullOrEmpty(Model.SearchTerm) ||
- !string.IsNullOrEmpty(Model.LabelFilter);
-
- var wallet = !string.IsNullOrEmpty(Model.WalletId) ? WalletId.Parse(Model.WalletId) : null;
+ var statusFilterCount = Model.Search.CountArrayFilter("status") + (Model.Search.HasBooleanFilter("includearchived") ? 1 : 0);
}
@section PageHeadContent {
@@ -25,21 +15,6 @@
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
}
-@functions
-{
- private int CountArrayFilter(string type) =>
- Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0;
-
- private bool HasArrayFilter(string type, string key = null) =>
- Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key));
-
- private bool HasBooleanFilter(string key) =>
- Model.Search.ContainsFilter(key) && Model.Search.GetFilterBool(key) is true;
-
- private bool HasCustomDateFilter() =>
- Model.Search.ContainsFilter("startdate") && Model.Search.ContainsFilter("enddate");
-}
-
<div class="sticky-header">
<h2>
@ViewData["Title"]
@@ -80,59 +55,13 @@
<partial name="_StatusMessage" />
-@* Custom Range Modal *@
-<div class="modal fade" id="customRangeModal" tabindex="-1" role="dialog" aria-labelledby="customRangeModalTitle" aria-hidden="true" data-bs-backdrop="static">
- <div class="modal-dialog modal-dialog-centered" role="document" style="max-width: 550px;">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title" id="customRangeModalTitle" text-translate="true">Filter payment requests by Custom Range</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
- <vc:icon symbol="close" />
- </button>
- </div>
- <div class="modal-body">
- <div class="form-group row">
- <label for="dtpStartDate" class="col-sm-3 col-form-label">Start Date</label>
- <div class="col-sm-9">
- <div class="input-group">
- <input id="dtpStartDate" class="form-control flatdtpicker" type="datetime-local"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
- placeholder="@StringLocalizer["Start Date"]" />
- <button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
- <vc:icon symbol="close" />
- </button>
- </div>
- </div>
- </div>
- <div class="form-group row">
- <label class="col-sm-3 col-form-label" text-translate="true">End Date</label>
- <div class="col-sm-9">
- <div class="input-group">
- <input id="dtpEndDate" class="form-control flatdtpicker" type="datetime-local"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
- placeholder="@StringLocalizer["End Date"]" />
- <button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
- <vc:icon symbol="close" />
- </button>
- </div>
- </div>
- </div>
- </div>
- <div class="modal-footer">
- <button id="btnCustomRangeDate" type="button" class="btn btn-primary" text-translate="true">Filter</button>
- </div>
- </div>
- </div>
-</div>
-
<form asp-action="GetPaymentRequests"
asp-route-storeId="@storeId"
method="get"
class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 col-xxl-8">
<input type="hidden" asp-for="Count" />
- <input type="hidden" asp-for="TimezoneOffset" />
- <input asp-for="SearchTerm" type="hidden" value="@Model.Search.WithoutSearchText()"/>
- <input asp-for="SearchText" class="form-control" placeholder="@StringLocalizer["Search by Id, Title or Amount..."]" />
+
+ <vc:search-string-input search-string="Model.Search" placeholder="@StringLocalizer["Search by Id, Title or Amount..."]" ></vc:search-string-input>
<div class="dropdown">
<button id="StatusOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@@ -146,140 +75,16 @@
}
</button>
<div class="dropdown-menu" aria-labelledby="StatusOptionsToggle">
- <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@Model.Search.Toggle("status", "pending")" class="dropdown-item @(HasArrayFilter("status", "pending") ? "custom-active" : "")" text-translate="true">Pending</a>
- <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@Model.Search.Toggle("status", "completed")" class="dropdown-item @(HasArrayFilter("status", "completed") ? "custom-active" : "")" text-translate="true">Settled</a>
- <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@Model.Search.Toggle("status", "expired")" class="dropdown-item @(HasArrayFilter("status", "expired") ? "custom-active" : "")" text-translate="true">Expired</a>
+ <button type="submit" name="FilterCommand" value="set-multi:status=pending" class="dropdown-item @(Model.Search.HasArrayFilter("status", "pending") ? "custom-active" : "")" text-translate="true">Pending</button>
+ <button type="submit" name="FilterCommand" value="set-multi:status=completed" class="dropdown-item @(Model.Search.HasArrayFilter("status", "completed") ? "custom-active" : "")" text-translate="true">Settled</button>
+ <button type="submit" name="FilterCommand" value="set-multi:status=expired" class="dropdown-item @(Model.Search.HasArrayFilter("status", "expired") ? "custom-active" : "")" text-translate="true">Expired</button>
<div role="separator" class="dropdown-divider"></div>
- <a asp-action="GetPaymentRequests" asp-route-storeId="@storeId" asp-route-count="@Model.Count" asp-route-searchText="@Model.SearchText" asp-route-searchTerm="@Model.Search.Toggle("includearchived", "true")" class="dropdown-item @(HasBooleanFilter("includearchived") ? "custom-active" : "")" id="StatusOptionsIncludeArchived" text-translate="true">Include Archived</a>
- </div>
- </div>
- <div class="dropdown">
- <button id="DateOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
- @if (hasDateFilter)
- {
- if (HasArrayFilter("startdate", "-1d"))
- {
- <span text-translate="true">24 Hours</span>
- }
- else if (HasArrayFilter("startdate", "-3d"))
- {
- <span text-translate="true">3 Days</span>
- }
- else if (HasArrayFilter("startdate", "-7d"))
- {
- <span text-translate="true">7 Days</span>
- }
- else
- {
- <span text-translate="true">Custom</span>
- }
- }
- else
- {
- <span text-translate="true">All Time</span>
- }
- </button>
- <div class="dropdown-menu" aria-labelledby="DateOptionsToggle">
- <a asp-action="GetPaymentRequests"
- asp-route-storeId="@storeId"
- asp-route-count="@Model.Count"
- asp-route-searchText="@Model.SearchText"
- asp-route-searchTerm="@Model.Search.Toggle("startdate", "-1d")"
- class="dropdown-item @(HasArrayFilter("startdate", "-1d") ? "custom-active" : "")"
- text-translate="true">
- Last 24 hours
- </a>
- <a asp-action="GetPaymentRequests"
- asp-route-storeId="@storeId"
- asp-route-count="@Model.Count"
- asp-route-searchText="@Model.SearchText"
- asp-route-searchTerm="@Model.Search.Toggle("startdate", "-3d")"
- class="dropdown-item @(HasArrayFilter("startdate", "-3d") ? "custom-active" : "")"
- text-translate="true">
- Last 3 days
- </a>
- <a asp-action="GetPaymentRequests"
- asp-route-storeId="@storeId"
- asp-route-count="@Model.Count"
- asp-route-searchText="@Model.SearchText"
- asp-route-searchTerm="@Model.Search.Toggle("startdate", "-7d")"
- class="dropdown-item @(HasArrayFilter("startdate", "-7d") ? "custom-active" : "")"
- text-translate="true">
- Last 7 days
- </a>
- <button type="button"
- class="dropdown-item @(HasCustomDateFilter() ? "custom-active" : "")"
- data-bs-toggle="modal"
- data-bs-target="#customRangeModal"
- text-translate="true">
- Custom Range
- </button>
+ <button type="submit" name="FilterCommand" value="set:includearchived=true" class="dropdown-item @(Model.Search.HasBooleanFilter("includearchived") ? "custom-active" : "")" id="StatusOptionsIncludeArchived" text-translate="true">Include Archived</button>
</div>
</div>
- @if (Model.Labels.Any())
- {
- <div class="dropdown">
- <button id="LabelOptionsToggle"
- class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret"
- type="button"
- data-bs-toggle="dropdown"
- aria-expanded="false">
- @if (string.IsNullOrEmpty(Model.LabelFilter))
- {
- <span text-translate="true">All Labels</span>
- }
- else
- {
- <span text-translate="true">Label:</span>
- <span>@Model.LabelFilter</span>
- }
- </button>
-
- <ul class="dropdown-menu" aria-labelledby="LabelOptionsToggle">
- @foreach (var label in Model.Labels)
- {
- <li>
- <a asp-route-labelFilter="@label.Text"
- asp-route-searchText="@Model.SearchText"
- class="dropdown-item transaction-label-text@(Model.LabelFilter == label.Text ? " active" : string.Empty)"
- style="--btcpay-dropdown-link-active-bg:@label.Color;--btcpay-dropdown-link-active-color:@label.TextColor;">
- @label.Text
- </a>
- </li>
- }
-
- @if (!string.IsNullOrEmpty(Model.LabelFilter))
- {
- <li><hr class="dropdown-divider" /></li>
- <li>
- <a asp-route-labelFilter=""
- class="dropdown-item text-danger"
- text-translate="true">
- Clear label filter
- </a>
- </li>
- }
- <li><hr class="dropdown-divider" /></li>
- <li>
- <a asp-action="PaymentRequestLabels" asp-controller="UIPaymentRequest" asp-route-storeId="@storeId"
- class="dropdown-item"
- text-translate="true">
- Manage Labels
- </a>
- </li>
- </ul>
- </div>
- }
- @if (hasAnyFilter)
- {
- <button id="clearAllFiltersBtn"
- type="button"
- class="btn btn-secondary ms-auto"
- style="min-width: 7rem;"
- title="@StringLocalizer["Clear all filters"]">
- <span class="align-middle" text-translate="true">Clear All</span>
- </button>
- }
+ <vc:label-selector labels="Model.Labels" search="Model.Search"></vc:label-selector>
+ <vc:date-range-selector search="Model.Search" custom-range-title='@StringLocalizer["Filter payment requests by Custom Range"].Value'></vc:date-range-selector>
+ <vc:clear-all-filters search="Model.Search"></vc:clear-all-filters>
</form>
@if (Model.Items.Any())
@@ -288,16 +93,7 @@
<table class="table table-hover">
<thead>
<tr>
- <th class="date-col">
- <div class="d-flex align-items-center gap-3">
- <span text-translate="true">Date</span>
- <button type="button"
- class="btn btn-link p-0 switch-time-format only-for-js"
- title="@StringLocalizer["Switch date format"]">
- <vc:icon symbol="time" />
- </button>
- </div>
- </th>
+ <vc:date-column></vc:date-column>
<th text-translate="true">Title</th>
<th style="width: 20px;"></th>
<th text-translate="true">Id</th>
@@ -383,53 +179,4 @@ else
@section PageFootContent {
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
-
- <script>
- document.addEventListener("DOMContentLoaded", function () {
- const clearBtn = document.getElementById('clearAllFiltersBtn');
- if (clearBtn) {
- clearBtn.addEventListener('click', function () {
- const form = clearBtn.closest('form');
- if (!form) return;
-
- const searchText = form.querySelector('[name="SearchText"]');
- if (searchText) searchText.value = '';
-
- const searchTerm = form.querySelector('[name="SearchTerm"]');
- if (searchTerm) searchTerm.value = '';
-
- form.submit();
- });
- }
- });
-
- $('#btnCustomRangeDate').on('click', function () {
- var filterString = "";
-
- var dtpStartDate = $("#dtpStartDate").val();
- if (dtpStartDate) {
- filterString = "startdate%3A" + encodeURIComponent(dtpStartDate);
- }
-
- var dtpEndDate = $("#dtpEndDate").val();
- if (dtpEndDate) {
- if (filterString !== "") {
- filterString += ",";
- }
- filterString += "enddate%3A" + encodeURIComponent(dtpEndDate);
- }
-
- if (filterString) {
- var baseUrl = "@Url.Action("GetPaymentRequests", new { storeId = storeId })";
- var redirectUri = baseUrl +
- "?Count=" + $("#Count").val() +
- "&timezoneoffset=" + $("#TimezoneOffset").val() +
- "&SearchTerm=" + filterString +
- "&SearchText=" + encodeURIComponent($('input[name="SearchText"]').val() || '');
- window.location.href = redirectUri;
- } else {
- $("#dtpStartDate").next().trigger("focus");
- }
- });
- </script>
}
diff --git a/BTCPayServer/Views/UIReports/StoreReports.cshtml b/BTCPayServer/Views/UIReports/StoreReports.cshtml
index f993606..55f90eb 100644
--- a/BTCPayServer/Views/UIReports/StoreReports.cshtml
+++ b/BTCPayServer/Views/UIReports/StoreReports.cshtml
@@ -60,14 +60,14 @@
<div class="form-group">
<label for="fromDate" class="form-label">@StringLocalizer["Start Date"]</label>
<input id="fromDate" name="fromDate"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
+ data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "defaultHour": 0 }'
class="form-control flatdtpicker" placeholder="@StringLocalizer["Start Date"]" />
</div>
<div class="form-group">
<label for="toDate" class="form-label">@StringLocalizer["End Date"]</label>
<input id="toDate" name="toDate" class="form-control flatdtpicker"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
+ data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "defaultHour": 0 }'
placeholder="@StringLocalizer["End Date"]" />
</div>
<div id="searchGroup" v-cloak class="form-group d-flex align-items-end">
@@ -131,7 +131,8 @@
<h3 id="raw-data">Raw data</h3>
<div id="raw-data-table" class="table-responsive" v-if="srv.result.data.length">
<table class="table table-hover">
- <thead class="sticky-top bg-body">
+ <!-- No sticky-top because the headers then hide the calendar -->
+ <thead class="bg-body">
<tr>
<th v-for="field in srv.result.fields" :class="{ 'text-end': ['integer', 'decimal', 'amount'].includes(field.type) }">
<a class="text-nowrap sort-column"
@@ -171,10 +172,12 @@
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" />
</template>
<template v-else-if="srv.result.fields[columnIndex].type === 'datetime'">{{ displayDate(value) }}</template>
- <span v-else-if="srv.result.fields[columnIndex].type === 'boolean' && value === true"><vc:icon symbol="checkmark"
- css-class="text-success" /></span>
- <span v-else-if="srv.result.fields[columnIndex].type === 'boolean' && value === false"><vc:icon symbol="cross"
- css-class="text-danger" /></span>
+ <span v-else-if="srv.result.fields[columnIndex].type === 'boolean' && value === true">
+ <vc:icon symbol="checkmark"
+ css-class="text-success" /></span>
+ <span v-else-if="srv.result.fields[columnIndex].type === 'boolean' && value === false">
+ <vc:icon symbol="cross"
+ css-class="text-danger" /></span>
<span
v-else-if="['BalanceChange'].includes(srv.result.fields[columnIndex].name) && (value >= 0 || (typeof value === 'object' && value.v >= 0))"
class="text-success">{{ displayValue(value) }}</span>
diff --git a/BTCPayServer/Views/UIStorePullPayments/Payouts.cshtml b/BTCPayServer/Views/UIStorePullPayments/Payouts.cshtml
index 4ef0bdd..3eb1639 100644
--- a/BTCPayServer/Views/UIStorePullPayments/Payouts.cshtml
+++ b/BTCPayServer/Views/UIStorePullPayments/Payouts.cshtml
@@ -151,14 +151,7 @@
<input type="checkbox" class="form-check-input mass-action-select-all" data-payout-state="@Model.PayoutState.ToString()" />
</th>
}
- <th class="date-col">
- <div class="d-flex align-items-center gap-3">
- <span text-translate="true">Date</span>
- <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>
+ <vc:date-column></vc:date-column>
<th text-translate="true">Source</th>
<th text-translate="true">Destination</th>
<th text-translate="true" class="amount-col">Amount</th>
diff --git a/BTCPayServer/wwwroot/js/store-reports.js b/BTCPayServer/wwwroot/js/store-reports.js
index 4e5c97d..634335a 100644
--- a/BTCPayServer/wwwroot/js/store-reports.js
+++ b/BTCPayServer/wwwroot/js/store-reports.js
@@ -125,7 +125,7 @@ document.addEventListener("DOMContentLoaded", () => {
srv.request.timePeriod.to = moment(to).unix();
srv.request.viewName = srv.request.viewName || "Invoices";
srv.request.timePeriod.from = moment(from).unix();
- srv.request.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ srv.request.timeZone = getDateFormatter().resolvedOptions().timeZone;
srv.result = {fields: [], values: []};
searchBtnApp = new Vue({
el: '#searchGroup',
@@ -155,7 +155,7 @@ document.addEventListener("DOMContentLoaded", () => {
fetchStoreReports();
});
-const dtFormatter = new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'});
+const dtFormatter = getDateFormatter();
function displayDate(val) {
if (!val) {
diff --git a/BTCPayServer/wwwroot/main/datetime.js b/BTCPayServer/wwwroot/main/datetime.js
new file mode 100644
index 0000000..ea16aaf
--- /dev/null
+++ b/BTCPayServer/wwwroot/main/datetime.js
@@ -0,0 +1,27 @@
+// Creates a localized date/time formatter with the given options, merging default formats and locales
+// By order of precedence:
+// 1. opts
+// 2. window.defaultDateTimeFormat (Typically defined in _Layout.cshtml)
+function getDateFormatter(opts) {
+ opts = opts || {};
+ const cleanOptions = options => Object.fromEntries(Object.entries(options).filter(([_, value]) => value != null));
+ const options = Object.assign(
+ cleanOptions(window.defaultDateTimeFormat || {}),
+ cleanOptions(opts));
+ const locale = opts.locale || (window.defaultDateTimeFormat || {}).locale || 'default';
+ // initialize and set localized attribute
+ return new Intl.DateTimeFormat(locale, options);
+}
+
+function formatDateTimes(format, root) {
+ root = root || document;
+ // select only elements which haven't been initialized before, those without data-localized
+ root.querySelectorAll("time[datetime]:not([data-localized])").forEach($el => {
+ const date = new Date($el.getAttribute("datetime"));
+ // initialize and set localized attribute
+ $el.dataset.localized = getDateFormatter($el.dataset).format(date);
+ // set text to chosen mode
+ const mode = format || $el.dataset.initial;
+ if ($el.dataset[mode]) $el.innerText = $el.dataset[mode];
+ });
+}
diff --git a/BTCPayServer/wwwroot/main/site.js b/BTCPayServer/wwwroot/main/site.js
index bbb4c9d..888a502 100644
--- a/BTCPayServer/wwwroot/main/site.js
+++ b/BTCPayServer/wwwroot/main/site.js
@@ -326,13 +326,23 @@ document.addEventListener("DOMContentLoaded", () => {
setStickyHeaderHeight();
}
- // initialize timezone offset value if field is present in page
- const $timezoneOffset = document.getElementById("TimezoneOffset");
- const timezoneOffset = new Date().getTimezoneOffset();
- if ($timezoneOffset) $timezoneOffset.value = timezoneOffset;
-
// localize all elements that have localizeDate class
- formatDateTimes();
+ if (formatDateTimes)
+ formatDateTimes();
+
+ document.querySelectorAll("*[timezone], *[browser-timezone]").forEach($el => {
+ var formatter = $el.hasAttribute("timezone") ? getDateFormatter() : Intl.DateTimeFormat();
+ if (!formatter)
+ return;
+ if ($el.tagName === "INPUT") {
+ if (!$el.value)
+ $el.value = formatter.resolvedOptions().timeZone || '';
+ }
+ else if ($el.tagName === "SPAN") {
+ if (!$el.innerText)
+ $el.innerText = formatter.resolvedOptions().timeZone || '';
+ }
+ });
initLabelManagers();
@@ -351,9 +361,11 @@ document.addEventListener("DOMContentLoaded", () => {
var element = $(this);
var fdtp = element.attr("data-fdtp");
+ var time24 = getDateFormatter().resolvedOptions().hourCycle === "h23";
// support for initializing with special options per instance
if (fdtp) {
var parsed = Object.assign({}, JSON.parse(fdtp), { static: true });
+ parsed.time_24hr ??= time24;
flatpickrInstances.push(element.flatpickr(parsed));
} else {
var min = element.attr("min");
@@ -368,7 +380,7 @@ document.addEventListener("DOMContentLoaded", () => {
minDate: min,
maxDate: max,
defaultDate: defaultDate,
- time_24hr: true,
+ time_24hr: time24,
defaultHour: 0,
static: true
}));
diff --git a/BTCPayServer/wwwroot/main/utils.js b/BTCPayServer/wwwroot/main/utils.js
index 9c38e25..0636f87 100644
--- a/BTCPayServer/wwwroot/main/utils.js
+++ b/BTCPayServer/wwwroot/main/utils.js
@@ -16,17 +16,3 @@ function debounce(key, fn, delay = 250) {
DEBOUNCE_TIMERS[key] = setTimeout(fn, delay)
}
-function formatDateTimes(format, root) {
- root = root || document;
- // select only elements which haven't been initialized before, those without data-localized
- root.querySelectorAll("time[datetime]:not([data-localized])").forEach($el => {
- const date = new Date($el.getAttribute("datetime"));
- // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
- const { dateStyle = 'short', timeStyle = 'short' } = $el.dataset;
- // initialize and set localized attribute
- $el.dataset.localized = new Intl.DateTimeFormat('default', { dateStyle, timeStyle }).format(date);
- // set text to chosen mode
- const mode = format || $el.dataset.initial;
- if ($el.dataset[mode]) $el.innerText = $el.dataset[mode];
- });
-}
diff --git a/BTCPayServer/wwwroot/pos/common.js b/BTCPayServer/wwwroot/pos/common.js
index 859a23c..33f7916 100644
--- a/BTCPayServer/wwwroot/pos/common.js
+++ b/BTCPayServer/wwwroot/pos/common.js
@@ -175,7 +175,7 @@ const posCommon = {
amounts: [null],
recentTransactions: [],
recentTransactionsLoading: false,
- dateFormatter: new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' }),
+ dateFormatter: getDateFormatter(),
}
},
computed: {
Why this scored 24/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.