Feat: Improved Label System - Add search filtering to label dropdown menu (#7210)
What changed, and why it matters
This commit adds a search box to the wallet transaction label filter dropdown. When a wallet has more than 20 labels, users can now type to search instead of scrolling through a long list. It also shows how often each label is used and lists the 20 most popular labels first. There is no security-relevant change here.
No security action needed. This is a routine UI/UX feature commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change introduces client-side filtering for the label dropdown in WalletTransactions.cshtml, backed by a new repository query that returns labels with usage counts. It adds a Playwright test verifying the search behavior. The code does not modify authentication, authorization, input validation, output encoding, or any other security-sensitive path.
Changed components
BTCPayServer/Views/UIWallets/WalletTransactions.cshtmlBTCPayServer/Controllers/UIWalletsController.csBTCPayServer/Services/WalletRepository.csBTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.csBTCPayServer/Plugins/Translations/Translations.Default.csBTCPayServer.Tests/WalletTests.csInspect captured patch +273 / −23
diff --git a/BTCPayServer.Tests/WalletTests.cs b/BTCPayServer.Tests/WalletTests.cs
index 7ae07f9..bd2da1d 100644
--- a/BTCPayServer.Tests/WalletTests.cs
+++ b/BTCPayServer.Tests/WalletTests.cs
@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Payments;
using BTCPayServer.Services.Invoices;
@@ -605,6 +606,94 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
await w.AssertHasLabels("RBF");
}
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task CanSearchLabelFilterInWalletTransactions()
+ {
+ 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);
+
+ const int txCount = 22;
+ const int distinctLabelCount = 21;
+ for (var i = 0; i < txCount; i++)
+ {
+ await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(0.001m + i * 0.0001m));
+ }
+ await s.Server.ExplorerNode.GenerateAsync(1);
+
+ var client = await s.AsTestAccount().CreateClient();
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ var txs = await client.ShowOnChainWalletTransactions(s.StoreId, "BTC");
+ Assert.True(txs.Count() >= txCount);
+ });
+
+ const string targetLabel = "zz-smoke-popular-target";
+ var labels = Enumerable.Range(0, distinctLabelCount - 1)
+ .Select(i => $"smoke-alpha-{i:00}")
+ .ToArray();
+ var transactions = (await client.ShowOnChainWalletTransactions(s.StoreId, "BTC")).Take(txCount).ToArray();
+ for (var i = 0; i < transactions.Length; i++)
+ {
+ var label = i < 2 ? targetLabel : labels[i - 2];
+ await client.PatchOnChainWalletTransaction(
+ s.StoreId,
+ "BTC",
+ transactions[i].TransactionHash.ToString(),
+ new PatchOnChainTransactionRequest
+ {
+ Labels = new List<string> { label }
+ });
+ }
+
+ await s.GoToWalletTransactions(s.WalletId);
+ await s.Page.ClickAsync("#Filter button.dropdown-toggle");
+ await s.Page.Locator("#LabelDropdownMenu").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());
+
+ var targetItem = s.Page
+ .Locator("#LabelDropdownMenu .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")
+ .Filter(new() { Has = s.Page.Locator($".label-filter-text:text-is('{singleUseLabel}')") });
+ Assert.Equal("1", (await singleUseItem.Locator(".label-filter-count").InnerTextAsync()).Trim());
+ });
+
+ await s.Page.FillAsync("#LabelSearch", "target");
+ await TestUtils.EventuallyAsync(async () =>
+ {
+ var items = await s.Page.Locator("#LabelDropdownMenu .label-filter-item").CountAsync();
+ Assert.Equal(1, items);
+ Assert.Equal("2", (await s.Page.Locator("#LabelDropdownMenu .label-filter-item .label-filter-count").InnerTextAsync()).Trim());
+ });
+
+ await s.Page.ClickAsync($"#LabelDropdownMenu .label-filter-item a:has-text('{targetLabel}')");
+ await TestUtils.EventuallyAsync(() =>
+ {
+ Assert.Contains($"labelFilter={targetLabel}", s.Page.Url);
+ return Task.CompletedTask;
+ });
+
+ await s.InWalletTransactions().AssertHasLabels(targetLabel);
+ }
+
private async Task CreateInvoices(PlaywrightTester tester)
{
var client = await tester.AsTestAccount().CreateClient();
diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs
index 47b27d0..7b5293f 100644
--- a/BTCPayServer/Controllers/UIWalletsController.cs
+++ b/BTCPayServer/Controllers/UIWalletsController.cs
@@ -617,13 +617,21 @@ namespace BTCPayServer.Controllers
// We can't filter at the database level if we need to apply label filter
var preFiltering = string.IsNullOrEmpty(labelFilter);
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
+ const int maxVisibleLabels = 20;
model.PendingTransactions = await _pendingTransactionService.GetPendingTransactions(walletId.CryptoCode, walletId.StoreId);
model.Rates = GetCurrentStore().GetStoreBlob().GetTrackedRates().ToList();
- model.Labels.AddRange(
- (await WalletRepository.GetWalletLabelsByLinkedType(walletId, WalletObjectData.Types.Tx))
- .Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));
+ 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();
IList<TransactionHistoryLine>? transactions = null;
Dictionary<string, WalletTransactionInfo>? walletTransactionsInfo = null;
diff --git a/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs b/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs
index 7b39e27..f036ab6 100644
--- a/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs
+++ b/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs
@@ -26,7 +26,8 @@ namespace BTCPayServer.Models.WalletViewModels
public string InvoiceId { get; set; }
public TransactionHistoryLine HistoryLine { get; set; }
}
- public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new();
+ 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 List<TransactionViewModel> Transactions { get; set; } = new();
public override int CurrentPageCount => Transactions.Count;
public string CryptoCode { get; set; }
diff --git a/BTCPayServer/Plugins/Translations/Translations.Default.cs b/BTCPayServer/Plugins/Translations/Translations.Default.cs
index fe5152e..a43a4b2 100644
--- a/BTCPayServer/Plugins/Translations/Translations.Default.cs
+++ b/BTCPayServer/Plugins/Translations/Translations.Default.cs
@@ -1426,6 +1426,7 @@ namespace BTCPayServer.Plugins.Translations
"Search by Id, Title or Amount...": "",
"Search engines can index this site": "",
"Search…": "",
+ "Search labels…": "",
"seconds": "",
"Secure your recovery phrase": "",
"Security device (FIDO2)": "",
diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs
index e3b90cb..50df4c5 100644
--- a/BTCPayServer/Services/WalletRepository.cs
+++ b/BTCPayServer/Services/WalletRepository.cs
@@ -69,6 +69,8 @@ namespace BTCPayServer.Services
#nullable restore
public class WalletRepository
{
+ public record WalletLabelUsage(string Label, string Color, long UsageCount);
+
private readonly ApplicationDbContextFactory _ContextFactory;
public WalletRepository(ApplicationDbContextFactory contextFactory)
@@ -303,28 +305,59 @@ namespace BTCPayServer.Services
}
public async Task<(string Label, string Color)[]> GetWalletLabelsByLinkedType(WalletId walletId, string linkedType)
+ {
+ return (await GetWalletLabelsByLinkedTypeWithUsage(walletId, linkedType))
+ .Select(l => (l.Label, l.Color))
+ .ToArray();
+ }
+
+ public async Task<WalletLabelUsage[]> GetWalletLabelsByLinkedTypeWithUsage(WalletId walletId, string linkedType, bool includeUnusedLabels = false)
{
await using var ctx = _ContextFactory.CreateContext();
+ await using var conn = ctx.Database.GetDbConnection();
+ await conn.OpenAsync();
const string sql = """
- SELECT DISTINCT
- wo.*,
- wo.xmin AS "xmin"
- FROM "WalletObjectLinks" AS wol
- INNER JOIN "WalletObjects" AS wo
+ SELECT
+ wo."Id" AS "Label",
+ wo."Data"->>'color' AS "Color",
+ COUNT(wol."BId")::bigint AS "UsageCount"
+ FROM "WalletObjects" AS wo
+ LEFT JOIN "WalletObjectLinks" AS wol
ON wol."WalletId" = wo."WalletId"
- AND wol."AType" = wo."Type"
+ AND wol."AType" = @LabelType
AND wol."AId" = wo."Id"
- WHERE wol."WalletId" = {0}
- AND wol."AType" = {2}
- AND wol."BType" = {1};
+ AND wol."BType" = @LinkedType
+ WHERE wo."WalletId" = @WalletId
+ AND wo."Type" = @LabelType
+ GROUP BY wo."Id", wo."Data";
""";
- var labelObjects = await ctx.WalletObjects
- .FromSqlRaw(sql, walletId.ToString(), linkedType, WalletObjectData.Types.Label)
- .AsNoTracking()
- .ToArrayAsync();
- return labelObjects.Select(FormatToLabel).ToArray();
+ var rows = await conn.QueryAsync<WalletLabelUsageRow>(sql,
+ new
+ {
+ WalletId = walletId.ToString(),
+ LabelType = WalletObjectData.Types.Label,
+ LinkedType = linkedType
+ });
+ var result = rows.Select(r => new WalletLabelUsage(
+ r.Label,
+ string.IsNullOrEmpty(r.Color) ? ColorPalette.Default.DeterministicColor(r.Label) : r.Color,
+ r.UsageCount))
+ .ToArray();
+ if (includeUnusedLabels)
+ {
+ return result;
+ }
+
+ return result.Where(r => r.UsageCount > 0).ToArray();
+ }
+
+ private class WalletLabelUsageRow
+ {
+ public string Label { get; set; } = string.Empty;
+ public string? Color { get; set; }
+ public long UsageCount { get; set; }
}
public async Task<Dictionary<string, (string Label, string Color)[]>> GetWalletLabelsForObjects(
diff --git a/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml b/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml
index eb617c7..f6bc571 100644
--- a/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml
+++ b/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml
@@ -9,6 +9,10 @@
var labelFilter = Context.Request.Query["labelFilter"].ToString();
var wallet = walletId != null ? WalletId.Parse(walletId) : new WalletId(storeId, cryptoCode);
storeId = wallet.StoreId;
+ 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)));
@@ -28,6 +32,46 @@
}
}
+ #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-divider {
+ display: none;
+ }
+ #LabelDropdownMenu .label-filter-clear {
+ position: sticky;
+ bottom: 0;
+ background: var(--btcpay-dropdown-bg);
+ border-top: var(--bs-border-width, 1px) solid var(--bs-border-color, rgba(0,0,0,.175));
+ }
+ #LabelDropdownMenu .label-filter-clear .dropdown-item {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+ }
+ #LabelDropdownMenu .label-filter-item .label-filter-link {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ }
+ #LabelDropdownMenu .label-filter-item .label-filter-count {
+ margin-left: auto;
+ text-align: right;
+ opacity: 0.75;
+ }
+
/* pull actions area, so that it is besides the search form */
@@media (min-width: 1200px) {
#Filter + #Dropdowns {
@@ -61,6 +105,54 @@
window.scrollTo({ top: 0, behavior: 'smooth' });
});
+ const $labelSearch = document.getElementById('LabelSearch');
+ if ($labelSearch) {
+ let 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 activeLabel = @Safe.Json(labelFilter);
+ const $menu = document.getElementById('LabelDropdownMenu');
+
+ function renderLabelItems(labels) {
+ $menu.querySelectorAll('.label-filter-item').forEach(el => el.remove());
+ const divider = $menu.querySelector('.label-filter-divider');
+ 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 (label.text === activeLabel) 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);
+ });
+
+ if (divider) {
+ $menu.insertBefore(fragment, divider);
+ } else {
+ $menu.appendChild(fragment);
+ }
+ }
+
+ // Keep the dropdown open while typing in the search box
+ $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');
}
@@ -140,17 +232,43 @@
<span>@labelFilter</span>
}
</button>
- <ul class="dropdown-menu">
- @foreach (var label in Model.Labels)
+ @{
+ var visibleLabels = initialLabels;
+ }
+ <ul class="dropdown-menu mt-1 py-0" id="LabelDropdownMenu" style="min-width:280px">
+ <li class="px-2 pt-2 pb-2@(sortedLabels.Count() > maxVisibleLabels ? "" : " 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>
+
+ @foreach (var label in visibleLabels)
{
- <li><a asp-route-labelFilter="@label.Text" class="dropdown-item transaction-label-text@(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>
+ <li class="label-filter-item">
+ <a asp-route-labelFilter="@label.Text" class="dropdown-item transaction-label-text label-filter-link@(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>
}
@if (!string.IsNullOrEmpty(labelFilter))
{
- <li><hr class="dropdown-divider"></li>
- <li><a class="dropdown-item" asp-route-labelFilter="" text-translate="true">Clear filter</a></li>
+ <li class="label-filter-divider"><hr class="dropdown-divider"></li>
+ <li class="label-filter-clear"><a class="dropdown-item" asp-route-labelFilter="" text-translate="true">Clear filter</a></li>
}
</ul>
+ <template id="label-filter-item-template">
+ <li class="label-filter-item">
+ <a asp-route-labelFilter="LABEL_PLACEHOLDER" 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>
}
Why this scored 15/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.