Files
btcpayserver/BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml
thgO.O 632d4433e0 feat: add Reserved Addresses view with filtering, pagination and labels (#6796)
* feat: add reservedAt metadata when address is generated from receive

* feat: add link to Reserved Addresses in wallet navigation

* feat: add ReservedAddressesViewModel with labels and reserved timestamp

* feat: implement Reserved Addresses view with filtering, pagination and label management

* feat: add GetReservedAddressesWithDetails with label and timestamp support

* feat: add ReservedAddresses endpoint

* test: add Reserved Addresses view test with label, filter and pagination

* test: use stable ID for filter input instead of placeholder

* Moving Reserved Addresses to Receive page

* feat: sync labels created via Label Manager using labelmanager:changed event

* refactor: optimize GetReservedAddressesWithDetails using direct SQL query

* feat: add link to filter Reserved Addresses by label from Wallet Labels view

* refactor: remove legacy selenium test

* test: add playwright tests with label filtering, pagination and redirect from Wallet Labels view

* refactor: optimize Reserved Addresses filtering with Set and Safe.Json

---------

Co-authored-by: rockstardev <5191402+rockstardev@users.noreply.github.com>
2025-07-04 09:30:23 +09:00

180 lines
7.7 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@using BTCPayServer.Components.LabelManager
@using BTCPayServer.Services
@model ReservedAddressesViewModel
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@{
var walletId = Context.GetRouteValue("walletId")?.ToString();
var storeId = Context.GetRouteValue("storeId")?.ToString();
var cryptoCode = Context.GetRouteValue("cryptoCode")?.ToString();
var wallet = walletId != null ? WalletId.Parse(walletId) : new WalletId(storeId, cryptoCode);
}
@{
ViewData.SetActivePage(WalletsNavPages.Receive, "Reserved Addresses");
Csp.Add("worker-src", "blob:");
Csp.UnsafeEval();
}
<div id="reserved-addresses">
<div class="sticky-header">
<vc:wallet-nav wallet-id="wallet"/>
<input id="filter-reserved-addresses" type="text" v-model="filter" class="form-control my-3" placeholder="@StringLocalizer["Filter by address, label or date"]" />
</div>
<div v-if="filteredItems.length > 0" class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th text-translate="true">Address</th>
<th text-translate="true">Label</th>
<th text-translate="true">Reserved At</th>
</tr>
</thead>
<tbody>
@foreach (var address in Model.Addresses)
{
<tr v-show='filteredItemsSet.has(@Safe.Json(address.Address))'>
<td>@address.Address</td>
<td>
<vc:label-manager
wallet-object-id="new WalletObjectId(wallet, WalletObjectData.Types.Address, address.Address)"
selected-labels="address.Labels.Select(l => l.Text).ToArray()"
rich-label-info="address.Labels.Where(l => !string.IsNullOrEmpty(l.Link)).ToDictionary(l => l.Text, l => new RichLabelInfo { Link = l.Link, Tooltip = l.Tooltip })"
exclude-types="false"
display-inline="true" />
</td>
<td>@address.ReservedAt?.ToString("g")</td>
</tr>
}
</tbody>
</table>
<div class="d-flex flex-wrap gap-3 justify-content-between mt-3 align-items-center">
<ul class="pagination mb-0">
<li class="page-item" :class="{ disabled: pageStart === 0 }">
<a class="page-link p-0" href="#" v-on:click.prevent="page = page - 1">«</a>
</li>
<li class="page-item disabled">
<span class="page-link p-0" text-translate="true">Showing {{ pageStart + 1 }}{{ pageEnd }} of {{ @Model.Addresses.Count }}</span>
</li>
<li class="page-item" :class="{ disabled: pageEnd >= @Model.Addresses.Count }">
<a class="page-link p-0" href="#" v-on:click.prevent="page = page + 1">»</a>
</li>
</ul>
<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="setPageSize(10)">10</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 25}" v-on:click="setPageSize(25)">25</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 50}" v-on:click="setPageSize(50)">50</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 100}" v-on:click="setPageSize(100)">100</button>
<button type="button" class="btn btn-link p-0" :class="{active: pageSize === 250}" v-on:click="setPageSize(250)">250</button>
</div>
</div>
</div>
</div>
<div v-else>
<p text-translate="true">No reserved addresses found.</p>
</div>
</div>
@section PageHeadContent {
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
<style>
.table td, .table th {
vertical-align: middle;
}
</style>
<style>
.btn-link.active {
--btcpay-btn-active-color: var(--btcpay-body-link);
}
.btn-link {
--btcpay-btn-color: var(--btcpay-body-text-muted);
}
.btn-link:hover {
--btcpay-btn-color: var(--btcpay-body-link-accent);
}
</style>
}
@section PageFootContent {
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
new Vue({
el: '#reserved-addresses',
data: {
addresses: @Safe.Json(Model.Addresses),
page: 0,
pageSize: 10,
filter: "@Context.Request.Query["filter"]".toLowerCase(),
},
computed: {
pageStart() {
return this.page * this.pageSize;
},
pageEnd() {
return Math.min(this.pageStart + this.pageSize, this.addresses.length);
},
filteredItems() {
const search = this.filter.toLowerCase();
const result = [];
for (const addr of this.addresses) {
const matchesAddress = addr.address.toLowerCase().includes(search);
const matchesLabel = addr.labels?.some(l => l.text.toLowerCase().includes(search));
const matchesDate = addr.reservedAt?.toLowerCase().includes(search);
if (matchesAddress || matchesLabel || matchesDate) result.push(addr);
}
return result.slice(this.pageStart, this.pageEnd);
},
filteredItemsSet() {
return new Set(this.filteredItems.map(x => x.address));
}
},
mounted() {
initLabelManagers();
const labelManagerList = document.querySelectorAll("input.label-manager");
labelManagerList.forEach(labelManager => {
labelManager.addEventListener("labelmanager:changed", ({ detail }) => {
const { walletObjectId, labels: newLabels } = detail;
const targetAddress = this.addresses.find(addr => addr.address === walletObjectId);
if (!targetAddress) return;
const existingLabels = targetAddress.labels?.map(l => l.text) || [];
const merged = Array.from(new Set([...existingLabels, ...newLabels])).map(text => ({ text }));
this.$set(targetAddress, 'labels', merged);
});
});
},
methods: {
setPageSize(size) {
this.pageSize = size;
this.page = 0;
}
},
watch: {
filter() {
this.$nextTick(() => {
initLabelManagers();
});
}
}
});
});
</script>
}