feat(wallet): enhance Coin Selection with advanced filters and improved UX (#6755)

* feat: add timestamp on InputSelectionOption to enable date filtering

* feat(coin-selection): add page size options for 100, 250, and 500

* feat(coin-selection): add toggle all option

* feat(coin-selection): add filtering by amount and timestamp

* feat(coin-selection): add filter help section for advanced search options

* test: add tests for Coin Selection filters and select all

* refactor: remove unnecessary variable

* feat(coin-selection): enable copy-to-clipboard for filter examples

* refactor(coin-selection): separate sort and select-all controls into distinct layout blocks

* refactor(coin-selection): align selection logic with existing DOM-based approach

* test: refactor CanUseCoinSelection and CanUseCoinSelectionFilters

* test(coin-selection): replace magic numbers and delays with constants and Eventually

* refactor(coin-selection): improve select all performance with specific jQuery selector

* test(coin-selection): improve select all test with eventually and Equal

* Monoscaped font and right aligning for easier amounts view

* fix: coin selection toggling no longer unchecks unrelated inputs

* refactor(coin-selection): ensure UTXO date filters use proper Date comparison

* feat: add text-translate attributes for available filters section

* test: make Select All checkbox interaction more resilient to race conditions

---------

Co-authored-by: rockstardev <5191402+rockstardev@users.noreply.github.com>
This commit is contained in:
thgO.O
2025-06-13 03:50:57 -03:00
committed by GitHub
parent 9a43139215
commit e1d68cb084
4 changed files with 225 additions and 41 deletions

View File

@@ -1257,6 +1257,21 @@ namespace BTCPayServer.Tests
s.Driver.WaitForElement(By.Id(spentOutpoint.ToString())); s.Driver.WaitForElement(By.Id(spentOutpoint.ToString()));
Assert.Equal("true", Assert.Equal("true",
s.Driver.FindElement(By.Name("InputSelection")).GetAttribute("value").ToLowerInvariant()); s.Driver.FindElement(By.Name("InputSelection")).GetAttribute("value").ToLowerInvariant());
//Select All test
s.Driver.WaitForAndClick(By.Id("select-all-checkbox"));
var inputSelectionSelectAll = s.Driver.FindElement(By.Name("SelectedInputs"));
TestUtils.Eventually(() => {
var selectedOptions = inputSelectionSelectAll.FindElements(By.CssSelector("option[selected]"));
var listItems = s.Driver.FindElements(By.CssSelector("li.list-group-item"));
Assert.Equal(listItems.Count, selectedOptions.Count);
});
s.Driver.WaitForAndClick(By.Id("select-all-checkbox"));
TestUtils.Eventually(() => {
var selectedOptions = inputSelectionSelectAll.FindElements(By.CssSelector("option[selected]"));
Assert.Empty(selectedOptions);
});
s.Driver.FindElement(By.Id(spentOutpoint.ToString())); s.Driver.FindElement(By.Id(spentOutpoint.ToString()));
s.Driver.FindElement(By.Id(spentOutpoint.ToString())).Click(); s.Driver.FindElement(By.Id(spentOutpoint.ToString())).Click();
var inputSelectionSelect = s.Driver.FindElement(By.Name("SelectedInputs")); var inputSelectionSelect = s.Driver.FindElement(By.Name("SelectedInputs"));
@@ -1275,6 +1290,79 @@ namespace BTCPayServer.Tests
Assert.Equal(spentOutpoint, tx.Inputs[0].PrevOut); Assert.Equal(spentOutpoint, tx.Inputs[0].PrevOut);
} }
[Fact(Timeout = TestTimeout)]
public async Task CanUseCoinSelectionFilters()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
s.RegisterNewUser(true);
(_, string storeId) = s.CreateNewStore();
s.GenerateWallet("BTC", "", false, true);
var walletId = new WalletId(storeId, "BTC");
s.GoToWallet(walletId, WalletsNavPages.Receive);
var addressStr = s.Driver.FindElement(By.Id("Address")).GetAttribute("data-text");
var address = BitcoinAddress.Create(addressStr,
((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
await s.Server.ExplorerNode.GenerateAsync(1);
const decimal AmountTiny = 0.001m;
const decimal AmountSmall = 0.005m;
const decimal AmountMedium = 0.009m;
const decimal AmountLarge = 0.02m;
List<uint256> txs =
[
await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(AmountTiny)),
await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(AmountSmall)),
await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(AmountMedium)),
await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(AmountLarge))
];
await s.Server.ExplorerNode.GenerateAsync(1);
s.GoToWallet(walletId);
s.Driver.WaitForAndClick(By.Id("toggleInputSelection"));
var input = s.Driver.WaitForElement(By.CssSelector("input[placeholder^='Filter']"));
Assert.NotNull(input);
// Test amountmin
input.Clear();
input.SendKeys("amountmin:0.01");
TestUtils.Eventually(() => {
Assert.Single(s.Driver.FindElements(By.CssSelector("li.list-group-item")));
});
// Test amountmax
input.Clear();
input.SendKeys("amountmax:0.002");
TestUtils.Eventually(() => {
Assert.Single(s.Driver.FindElements(By.CssSelector("li.list-group-item")));
});
// Test general text (txid)
input.Clear();
input.SendKeys(txs[2].ToString()[..8]);
TestUtils.Eventually(() => {
Assert.Single(s.Driver.FindElements(By.CssSelector("li.list-group-item")));
});
// Test timestamp before/after
input.Clear();
input.SendKeys("after:2099-01-01");
TestUtils.Eventually(() => {
Assert.Empty(s.Driver.FindElements(By.CssSelector("li.list-group-item")));
});
input.Clear();
input.SendKeys("before:2099-01-01");
TestUtils.Eventually(() =>
{
Assert.True(s.Driver.FindElements(By.CssSelector("li.list-group-item")).Count >= 4);
});
}
[Fact(Timeout = TestTimeout)] [Fact(Timeout = TestTimeout)]
public async Task CanUseWebhooks() public async Task CanUseWebhooks()
{ {

View File

@@ -996,7 +996,8 @@ namespace BTCPayServer.Controllers
Comment = info?.Comment, Comment = info?.Comment,
Labels = _labelService.CreateTransactionTagModels(info, Request), Labels = _labelService.CreateTransactionTagModels(info, Request),
Link = _transactionLinkProviders.GetTransactionLink(pmi, coin.OutPoint.ToString()), Link = _transactionLinkProviders.GetTransactionLink(pmi, coin.OutPoint.ToString()),
Confirmations = coin.Confirmations Confirmations = coin.Confirmations,
Timestamp = coin.Timestamp
}; };
}).ToArray(); }).ToArray();
} }

View File

@@ -79,6 +79,7 @@ namespace BTCPayServer.Models.WalletViewModels
public string Outpoint { get; set; } public string Outpoint { get; set; }
public string Link { get; set; } public string Link { get; set; }
public long Confirmations { get; set; } public long Confirmations { get; set; }
public DateTimeOffset? Timestamp { get; set; }
} }
} }
} }

View File

@@ -24,11 +24,49 @@
</span> </span>
</div> </div>
<input type="text" v-model="filter" class="form-control my-3" placeholder="@StringLocalizer["Filter by transaction id, amount, label, comment"]"/> <input type="text" v-model="filter" class="form-control my-3" placeholder="@StringLocalizer["Filter by transaction id, amount, label, comment"]"/>
<div class="d-flex gap-3 my-2 align-items-center"> <details class="text-muted small mt-2">
<summary text-translate="true">Available Filters (click to expand)</summary>
<ul class="mb-0">
<li class="mb-2"><b text-translate="true">General text search:</b> <span text-translate="true">txid, amount, comment, label</span></li>
<li class="mb-2">
<b text-translate="true">Minimum amount:</b>
<code class="clipboard-button clipboard-button-hover" :data-clipboard="'amountmin:'">amountmin:0.001</code>
</li>
<li class="mb-2">
<b text-translate="true">Maximum amount:</b>
<code class="clipboard-button clipboard-button-hover" :data-clipboard="'amountmax:'">amountmax:0.01</code>
</li>
<li class="mb-2">
<b text-translate="true">Created before date:</b>
<code class="clipboard-button clipboard-button-hover" :data-clipboard="'before:'">before:2024-01-01</code>
</li>
<li class="mb-2">
<b text-translate="true">Created after date:</b>
<code class="clipboard-button clipboard-button-hover" :data-clipboard="'after:'">after:2023-01-01</code>
</li>
<li class="mb-2"><b text-translate="true">Combine filters:</b> <code>tag1 amountmin:0.0005 before:2024-01-01</code></li>
</ul>
</details>
<div class="d-flex justify-content-between align-items-center my-2">
<div class="d-flex gap-2 align-items-center">
<input class="form-check-input flex-shrink-0"
id="select-all-checkbox"
type="checkbox"
:checked="isSelectAllChecked"
v-on:change="toggleSelectAllItems" />
<span text-translate="true">Select All</span>
</div>
<div class="d-flex gap-2 align-items-center">
<span text-translate="true">Sort by</span> <span text-translate="true">Sort by</span>
<div class="btn-group gap-3" role="group"> <div class="btn-group gap-3" role="group">
<button type="button" class="btn btn-link p-0" :class="{active: sortOrder.startsWith('amount')}" v-on:click="sortBy('amount')" text-translate="true">Amount</button> <button type="button" class="btn btn-link p-0"
<button type="button" class="btn btn-link p-0" :class="{active: sortOrder.startsWith('confs')}" v-on:click="sortBy('confs')" text-translate="true">Confirmations</button> :class="{active: sortOrder.startsWith('amount')}"
v-on:click="sortBy('amount')" text-translate="true">Amount</button>
<button type="button" class="btn btn-link p-0"
:class="{active: sortOrder.startsWith('confs')}"
v-on:click="sortBy('confs')" text-translate="true">Confirmations</button>
</div>
</div> </div>
</div> </div>
<ul class="list-group list-group-flush mb-3" v-show="filteredItems.length"> <ul class="list-group list-group-flush mb-3" v-show="filteredItems.length">
@@ -57,13 +95,13 @@
<span v-if="item.comment" data-bs-toggle="tooltip" v-tooltip="item.comment" class="badge bg-info rounded-pill" style="min-width:2em"> <span v-if="item.comment" data-bs-toggle="tooltip" v-tooltip="item.comment" class="badge bg-info rounded-pill" style="min-width:2em">
<vc:icon symbol="info" /> <vc:icon symbol="info" />
</span> </span>
<span class="text-muted text-nowrap flex-shrink-0 ms-2">{{item.amount}} @Model.CryptoCode</span>
<span :class="{'bg-secondary' : item.confirmations > 0, 'bg-warning' : item.confirmations <= 0}" <span :class="{'bg-secondary' : item.confirmations > 0, 'bg-warning' : item.confirmations <= 0}"
class="badge d-inline-flex align-items-center gap-1 py-1" class="badge d-inline-flex align-items-center gap-1 py-1"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
v-tooltip="`${item.confirmations} confirmation${item.confirmations === 1 ? '' : 's'}`"> v-tooltip="`${item.confirmations} confirmation${item.confirmations === 1 ? '' : 's'}`">
{{item.confirmations}} <vc:icon symbol="block" /> {{item.confirmations}} <vc:icon symbol="block" />
</span> </span>
<span class="text-nowrap flex-shrink-0 ms-2" style="font-family: 'Courier';">{{item.amount.toFixed(@Model.CryptoDivisibility)}} @Model.CryptoCode</span>
</div> </div>
</li> </li>
</ul> </ul>
@@ -92,15 +130,17 @@
</div> </div>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
<span class="text-muted" text-translate="true">Page Size:</span> <span class="text-muted" text-translate="true">Page Size:</span>
<div class="btn-group gap-3" role="group"> <div class="btn-group gap-2" role="group">
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 10}" v-on:click="pageSize = 10; page = 0">10</button> <button type="button" class="btn btn-link p-0" :class="{active: pageSize === 10}" v-on:click="pageSize = 10; page = 0">10</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 25}" v-on:click="pageSize = 25; page = 0">25</button> <button type="button" class="btn btn-link p-0" :class="{active: pageSize === 25}" v-on:click="pageSize = 25; page = 0">25</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 50}" v-on:click="pageSize = 50; page = 0">50</button> <button type="button" class="btn btn-link p-0" :class="{active: pageSize === 50}" v-on:click="pageSize = 50; page = 0">50</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 100}" v-on:click="pageSize = 100; page = 0">100</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 250}" v-on:click="pageSize = 250; page = 0">250</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 500}" v-on:click="pageSize = 500; page = 0">500</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
Vue.directive('tooltip', function(el, binding) { Vue.directive('tooltip', function(el, binding) {
@@ -161,31 +201,56 @@ document.addEventListener("DOMContentLoaded", function () {
hasUnconfirmed: function() { hasUnconfirmed: function() {
return this.items.filter(i => !i.confirmations).length > 0; return this.items.filter(i => !i.confirmations).length > 0;
}, },
filteredItems: function() { filteredItems: function () {
var result = []; const searchInput = this.filter.trim();
if (!this.filter && !this.showUnconfirmedOnly) { const parsedFilters = {};
var self = this; const filteredResults = [];
result = this.currentItems.map(function(currentItem) {
return {...currentItem, selected: self.selectedInputs.indexOf(currentItem.outpoint) != -1 if (searchInput) {
} for (const part of searchInput.split(" ")) {
}); const [key, value] = part.split(":");
if (value !== undefined) {
parsedFilters[key.toLowerCase()] = value;
} else { } else {
var f = this.filter.toLowerCase(); parsedFilters.text = parsedFilters.text ? parsedFilters.text + " " + key : key;
for (var i = 0; i < this.currentItems.length; i++) { }
var currentItem = this.currentItems[i]; }
if }
(currentItem.outpoint.indexOf(f) != -1 ||
currentItem.amount.toString().indexOf(f) != -1 || const amountMin = parsedFilters.amountmin ? parseFloat(parsedFilters.amountmin) : null;
(currentItem.comment && currentItem.comment.toLowerCase().indexOf(f) != -1) || const amountMax = parsedFilters.amountmax ? parseFloat(parsedFilters.amountmax) : null;
(currentItem.labels && currentItem.labels.filter(function(l) { const beforeDate = parsedFilters.before ? new Date(parsedFilters.before).getTime() : null;
return l.text.toLowerCase().indexOf(f) != -1 || l.tooltip.toLowerCase().indexOf(f) != -1 const afterDate = parsedFilters.after ? new Date(parsedFilters.after).getTime() : null;
}).length > 0)) { const searchTerm = parsedFilters.text?.toLowerCase();
result.push({...currentItem, selected: this.selectedInputs.indexOf(currentItem.outpoint) != -1
for (const utxo of this.currentItems) {
const matchesText = !searchTerm || (
utxo.outpoint.toLowerCase().includes(searchTerm) ||
utxo.amount.toString().includes(searchTerm) ||
(utxo.comment?.toLowerCase().includes(searchTerm)) ||
(utxo.labels?.some(l => l.text.toLowerCase().includes(searchTerm) || l.tooltip.toLowerCase().includes(searchTerm)))
);
if (!matchesText) continue;
if (amountMin !== null && utxo.amount < amountMin) continue;
if (amountMax !== null && utxo.amount > amountMax) continue;
if (beforeDate !== null && utxo.timestamp) {
const utxoTime = new Date(utxo.timestamp).getTime();
if (utxoTime >= beforeDate) continue;
}
if (afterDate !== null && utxo.timestamp) {
const utxoTime = new Date(utxo.timestamp).getTime();
if (utxoTime <= afterDate) continue;
}
filteredResults.push({
...utxo,
selected: this.selectedInputs.includes(utxo.outpoint)
}); });
} }
} return filteredResults.slice(this.pageStart, this.pageEnd);
}
return result.slice(this.pageStart, this.pageEnd);
}, },
selectedItems: function() { selectedItems: function() {
var result = []; var result = [];
@@ -204,6 +269,10 @@ document.addEventListener("DOMContentLoaded", function () {
result += this.selectedItems[i].amount; result += this.selectedItems[i].amount;
} }
return roundNumber(result, 12); return roundNumber(result, 12);
},
isSelectAllChecked: function () {
const visibleOutpoints = this.filteredItems.map(i => i.outpoint);
return visibleOutpoints.length > 0 && visibleOutpoints.every(outpoint => this.selectedInputs.includes(outpoint));
} }
}, },
mounted: function() { mounted: function() {
@@ -230,7 +299,7 @@ document.addEventListener("DOMContentLoaded", function () {
this.page = 0; this.page = 0;
}, },
handle: function() { handle: function() {
if (this.selectedInputs.length == 0) { if (this.selectedInputs.length === 0) {
this.showSelectedOnly = false; this.showSelectedOnly = false;
} }
if (this.currentItems.length < this.pageEnd) { if (this.currentItems.length < this.pageEnd) {
@@ -238,18 +307,41 @@ document.addEventListener("DOMContentLoaded", function () {
} }
}, },
toggleItem: function(evt, item, toggle) { toggleItem: function(evt, item, toggle) {
if (evt.target.tagName == "A") { if (evt.target.tagName === "A") {
return; return;
} }
var res = $("#SelectedInputs").val();
let res = $("#SelectedInputs").val() || [];
if (toggle) { if (toggle) {
res.push(item.outpoint); res.push(item.outpoint);
} else { } else {
res.splice(this.selectedInputs.indexOf(item.outpoint), 1); res = res.filter(i => i !== item.outpoint);
} }
$("select option").each(function() { $("select option").each(function () {
var selected = res.indexOf($(this).attr("value")) !== -1;
$(this).attr("selected", selected ? "selected" : null);
});
this.selectedInputs = res;
$(".crypto-balance-link").text(this.selectedAmount);
},
toggleSelectAllItems: function () {
const filteredOutpoints = this.filteredItems.map(item => item.outpoint);
let res = $("#SelectedInputs").val() || [];
const allSelected = filteredOutpoints.every(outpoint => res.includes(outpoint));
if (allSelected) {
res = res.filter(outpoint => !filteredOutpoints.includes(outpoint));
} else {
const set = new Set(res);
filteredOutpoints.forEach(outpoint => set.add(outpoint));
res = Array.from(set);
}
$("#SelectedInputs").find("option").each(function () {
var selected = res.indexOf($(this).attr("value")) !== -1; var selected = res.indexOf($(this).attr("value")) !== -1;
$(this).attr("selected", selected ? "selected" : null); $(this).attr("selected", selected ? "selected" : null);
}); });
@@ -276,3 +368,5 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
}); });
</script> </script>
<script src="~/js/copy-to-clipboard.js" asp-append-version="true"></script>