Files
btcpayserver/BTCPayServer/Views/UIWallets/CoinSelection.cshtml
thgO.O e1d68cb084 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>
2025-06-13 15:50:57 +09:00

373 lines
18 KiB
Plaintext

@model WalletSendModel
<div class="form-group hide-when-js">
<label asp-for="SelectedInputs" class="form-label"></label>
<select multiple="multiple" asp-for="SelectedInputs" class="form-select">
@foreach (var input in Model.InputsAvailable)
{
<option value="@input.Outpoint" class="text-truncate" asp-selected="@(Model.SelectedInputs?.Contains(input.Outpoint)??false)">(@input.Amount) @input.Outpoint</option>
}
</select>
</div>
<style>
#coin-selection-app .btn-link { --btcpay-btn-color: var(--btcpay-body-text-muted); }
#coin-selection-app .btn-link:hover { --btcpay-btn-color: var(--btcpay-body-link-accent); }
#coin-selection-app .btn-link.active { --btcpay-btn-active-color: var(--btcpay-body-link); }
</style>
<div id="coin-selection-app" class="only-for-js" v-cloak>
<div class="d-sm-flex justify-content-between align-items-center">
<h5 class="mb-sm-0" text-translate="true">Coin selection</h5>
<span class="text-muted text-end">
<span>@StringLocalizer["{0} selected", "{{selectedItems.length}}"]</span>
<span v-show="selectedItems.length > 0">({{selectedAmount}} @Model.CryptoCode)</span>
<span>/ @StringLocalizer["{0} total", "{{items.length}}"] (@Model.CurrentBalance&nbsp;@Model.CryptoCode)</span>
</span>
</div>
<input type="text" v-model="filter" class="form-control my-3" placeholder="@StringLocalizer["Filter by transaction id, amount, label, comment"]"/>
<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>
<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"
:class="{active: sortOrder.startsWith('confs')}"
v-on:click="sortBy('confs')" text-translate="true">Confirmations</button>
</div>
</div>
</div>
<ul class="list-group list-group-flush mb-3" v-show="filteredItems.length">
<li class="list-group-item py-2 cursor-pointer gap-2 align-items-center justify-content-between"
v-for="item of filteredItems"
:key="item.outpoint"
:class="{ 'active': item.selected }"
v-on:click="toggleItem($event, item, !item.selected)">
<div class="d-flex align-items-start flex-grow-1 gap-2" style="min-width:0">
<input class="form-check-input flex-shrink-0"
type="checkbox"
:id="item.outpoint"
:value="item.outpoint"
:checked="item.selected">
<a :href="item.link" target="_blank" class="text-truncate flex-grow-1" v-tooltip="item.outpoint">{{item.outpoint}}</a>
<div v-if="item.labels" class="d-flex flex-wrap gap-2 flex-fill" style="min-width:20%;max-width:40%;">
<span v-for="label of item.labels"
key="label.text"
class="transaction-label"
data-bs-toggle="tooltip"
v-tooltip="label.tooltip"
:style="styles(label.color)">
{{label.text}}
</span>
</div>
<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" />
</span>
<span :class="{'bg-secondary' : item.confirmations > 0, 'bg-warning' : item.confirmations <= 0}"
class="badge d-inline-flex align-items-center gap-1 py-1"
data-bs-toggle="tooltip"
v-tooltip="`${item.confirmations} confirmation${item.confirmations === 1 ? '' : 's'}`">
{{item.confirmations}} <vc:icon symbol="block" />
</span>
<span class="text-nowrap flex-shrink-0 ms-2" style="font-family: 'Courier';">{{item.amount.toFixed(@Model.CryptoDivisibility)}} @Model.CryptoCode</span>
</div>
</li>
</ul>
<div class="d-grid d-sm-flex flex-wrap gap-3 justify-content-between">
<ul class="pagination">
<li class="page-item" :class="{'disabled' : pageStart == 0}">
<a class="page-link p-0" tabindex="-1" href="#" v-on:click.prevent="page = page -1">«</a>
</li>
<li class="page-item disabled">
<span class="page-link p-0">@StringLocalizer["Showing {0} of {1}", "{{pageStart+1}}-{{pageEnd}}", "{{currentItems.length}}"]</span>
</li>
<li class="page-item" :class="{'disabled' : pageEnd>= currentItems.length}">
<a class="page-link p-0" href="#" v-on:click.prevent="page = page +1">»</a>
</li>
</ul>
<div class="btn-group gap-3">
<button type="button" :disabled="selectedInputs.length === 0" v-on:click="showSelectedOnly = !showSelectedOnly; page = 0" class="btn btn-link p-0">
<template text-translate="true" v-if="showSelectedOnly">Show all</template>
<template text-translate="true" v-else>Show selected only</template>
</button>
<button type="button" :disabled="showSelectedOnly" v-on:click="showUnconfirmedOnly = !showUnconfirmedOnly; page = 0" class="btn btn-link p-0" v-if="hasUnconfirmed">
<template text-translate="true" v-if="showUnconfirmedOnly">Show unconfirmed coins</template>
<template text-translate="true" v-else>Hide unconfirmed coins</template>
</button>
</div>
<div class="d-flex gap-2 align-items-center">
<span class="text-muted" text-translate="true">Page Size:</span>
<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 === 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 === 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>
<script>
document.addEventListener("DOMContentLoaded", function () {
Vue.directive('tooltip', function(el, binding) {
new bootstrap.Tooltip(el, {
title: binding.value,
placement: binding.arg || "auto",
trigger: 'hover'
});
});
function roundNumber(number, decimals) {
var newnumber = new Number(number + '').toFixed(parseInt(decimals));
return parseFloat(newnumber);
}
new Vue({
el: '#coin-selection-app',
data: {
filter: "",
items: @Safe.Json(Model.InputsAvailable),
selectedInputs: $("#SelectedInputs").val(),
page: 0,
pageSize: 10,
showSelectedOnly: false,
showUnconfirmedOnly: false,
sortOrder: 'amount-desc'
},
watch: {
filter: function() {
this.page = 0;
this.handle();
},
showSelectedOnly: function() {
this.handle();
},
selectedInputs: function() {
this.handle();
},
showUnconfirmedOnly: function() {
this.handle();
}
},
computed: {
currentItems: function() {
const items = this.showSelectedOnly ? this.selectedItems : this.items.filter(i=> (!this.showUnconfirmedOnly || i.confirmations ));
return this.sorted(items);
},
pageStart: function() {
return this.page === 0 ? 0 : this.page * this.pageSize;
},
pageEnd: function() {
var result = this.pageStart + this.pageSize;
if (result > this.currentItems.length) {
result = this.currentItems.length;
}
return result;
},
hasUnconfirmed: function() {
return this.items.filter(i => !i.confirmations).length > 0;
},
filteredItems: function () {
const searchInput = this.filter.trim();
const parsedFilters = {};
const filteredResults = [];
if (searchInput) {
for (const part of searchInput.split(" ")) {
const [key, value] = part.split(":");
if (value !== undefined) {
parsedFilters[key.toLowerCase()] = value;
} else {
parsedFilters.text = parsedFilters.text ? parsedFilters.text + " " + key : key;
}
}
}
const amountMin = parsedFilters.amountmin ? parseFloat(parsedFilters.amountmin) : null;
const amountMax = parsedFilters.amountmax ? parseFloat(parsedFilters.amountmax) : null;
const beforeDate = parsedFilters.before ? new Date(parsedFilters.before).getTime() : null;
const afterDate = parsedFilters.after ? new Date(parsedFilters.after).getTime() : null;
const searchTerm = parsedFilters.text?.toLowerCase();
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);
},
selectedItems: function() {
var result = [];
for (var i = 0; i < this.items.length; i++) {
var currentItem = this.items[i];
if (this.selectedInputs.indexOf(currentItem.outpoint) != -1) {
result.push(currentItem);
}
}
return result;
},
selectedAmount: function() {
var result = 0;
for (let i = 0; i < this.selectedItems.length; i++) {
result += this.selectedItems[i].amount;
}
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() {
var self = this;
self.selectedInputs = $("#SelectedInputs").val();
$(".crypto-balance-link").text(this.selectedAmount);
$("#SelectedInputs").on("input change", function() {
self.selectedInputs = $("#SelectedInputs").val();
});
},
methods: {
sorted: function(items) {
switch (this.sortOrder) {
case 'amount-desc': return items.sort((a, b) => b.amount - a.amount)
case 'amount-asc': return items.sort((a, b) => a.amount - b.amount)
case 'confs-desc': return items.sort((a, b) => b.confirmations - a.confirmations)
case 'confs-asc': return items.sort((a, b) => a.confirmations - b.confirmations)
default: return items;
}
},
sortBy: function(type) {
this.sortOrder = this.sortOrder === `${type}-desc` ? `${type}-asc` : `${type}-desc`
// reset page
this.page = 0;
},
handle: function() {
if (this.selectedInputs.length === 0) {
this.showSelectedOnly = false;
}
if (this.currentItems.length < this.pageEnd) {
this.page = 0;
}
},
toggleItem: function(evt, item, toggle) {
if (evt.target.tagName === "A") {
return;
}
let res = $("#SelectedInputs").val() || [];
if (toggle) {
res.push(item.outpoint);
} else {
res = res.filter(i => i !== item.outpoint);
}
$("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;
$(this).attr("selected", selected ? "selected" : null);
});
this.selectedInputs = res;
$(".crypto-balance-link").text(this.selectedAmount);
},
styles: function (bgColor) {
let color = 'inherit'
if (bgColor.slice(0, 1) === '#') {
const hex = bgColor.slice(1);
// Convert to RGB value
const r = parseInt(hex.substr(0,2), 16);
const g = parseInt(hex.substr(2,2), 16);
const b = parseInt(hex.substr(4,2), 16);
// Get YIQ ratio
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
// Check contrast
color = (yiq >= 128) ? 'black' : 'white';
}
return { '--label-bg': bgColor, '--label-fg': color }
}
}
});
});
</script>
<script src="~/js/copy-to-clipboard.js" asp-append-version="true"></script>