mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 06:24:24 +01:00
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>
This commit is contained in:
@@ -849,5 +849,91 @@ namespace BTCPayServer.Tests
|
|||||||
});
|
});
|
||||||
Assert.Contains(tx.ToString(), await File.ReadAllTextAsync(await download.PathAsync()));
|
Assert.Contains(tx.ToString(), await File.ReadAllTextAsync(await download.PathAsync()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CanUseReservedAddressesView()
|
||||||
|
{
|
||||||
|
await using var s = CreatePlaywrightTester();
|
||||||
|
await s.StartAsync();
|
||||||
|
await s.RegisterNewUser(true);
|
||||||
|
await s.CreateNewStore();
|
||||||
|
var walletId = new WalletId(s.StoreId, "BTC");
|
||||||
|
s.WalletId = walletId;
|
||||||
|
await s.GenerateWallet();
|
||||||
|
|
||||||
|
await s.GoToWallet(walletId, WalletsNavPages.Receive);
|
||||||
|
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var currentAddress = await s.Page.GetAttributeAsync("#Address", "data-text");
|
||||||
|
await s.Page.ClickAsync("button[value=generate-new-address]");
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
|
{
|
||||||
|
var newAddress = await s.Page.GetAttributeAsync("#Address[data-text]", "data-text");
|
||||||
|
Assert.False(string.IsNullOrEmpty(newAddress));
|
||||||
|
Assert.NotEqual(currentAddress, newAddress);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await s.Page.ClickAsync("#reserved-addresses-button");
|
||||||
|
await s.Page.WaitForSelectorAsync("#reserved-addresses");
|
||||||
|
|
||||||
|
const string labelInputSelector = "#reserved-addresses table tbody tr .ts-control input";
|
||||||
|
await s.Page.WaitForSelectorAsync(labelInputSelector);
|
||||||
|
|
||||||
|
// Test Label Manager
|
||||||
|
await s.Page.FillAsync(labelInputSelector, "test-label");
|
||||||
|
await s.Page.Keyboard.PressAsync("Enter");
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
|
{
|
||||||
|
var text = await s.Page.InnerTextAsync("#reserved-addresses table tbody");
|
||||||
|
Assert.Contains("test-label", text);
|
||||||
|
});
|
||||||
|
|
||||||
|
//Test Pagination
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
|
{
|
||||||
|
var rows = await s.Page.QuerySelectorAllAsync("#reserved-addresses table tbody tr");
|
||||||
|
var visible = await Task.WhenAll(rows.Select(async r => await r.IsVisibleAsync()));
|
||||||
|
Assert.Equal(10, visible.Count(v => v));
|
||||||
|
});
|
||||||
|
|
||||||
|
await s.Page.ClickAsync(".pagination li:last-child a");
|
||||||
|
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
|
{
|
||||||
|
var rows = await s.Page.QuerySelectorAllAsync("#reserved-addresses table tbody tr");
|
||||||
|
var visible = await Task.WhenAll(rows.Select(async r => await r.IsVisibleAsync()));
|
||||||
|
Assert.Single(visible, v => v);
|
||||||
|
});
|
||||||
|
|
||||||
|
await s.Page.ClickAsync(".pagination li:first-child a");
|
||||||
|
await s.Page.WaitForSelectorAsync("#reserved-addresses");
|
||||||
|
|
||||||
|
// Test Filter
|
||||||
|
await s.Page.FillAsync("#filter-reserved-addresses", "test-label");
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
|
{
|
||||||
|
var rows = await s.Page.QuerySelectorAllAsync("#reserved-addresses table tbody tr");
|
||||||
|
var visible = await Task.WhenAll(rows.Select(async r => await r.IsVisibleAsync()));
|
||||||
|
Assert.Single(visible, v => v);
|
||||||
|
});
|
||||||
|
|
||||||
|
//Test WalletLabels redirect with filter
|
||||||
|
await s.GoToWallet(walletId, WalletsNavPages.Settings);
|
||||||
|
await s.Page.ClickAsync("#manage-wallet-labels-button");
|
||||||
|
await s.Page.WaitForSelectorAsync("table");
|
||||||
|
await s.Page.ClickAsync("a:has-text('Addresses')");
|
||||||
|
|
||||||
|
await s.Page.WaitForSelectorAsync("#reserved-addresses");
|
||||||
|
var currentFilter = await s.Page.InputValueAsync("#filter-reserved-addresses");
|
||||||
|
Assert.Equal("test-label", currentFilter);
|
||||||
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
|
{
|
||||||
|
var rows = await s.Page.QuerySelectorAllAsync("#reserved-addresses table tbody tr");
|
||||||
|
var visible = await Task.WhenAll(rows.Select(r => r.IsVisibleAsync()));
|
||||||
|
Assert.Single(visible, v => v);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
<li class="nav-item nav-item-sub">
|
<li class="nav-item nav-item-sub">
|
||||||
<a id="WalletNav-Receive" class="nav-link @ViewData.ActivePageClass(WalletsNavPages.Receive, scheme.WalletId.ToString())" asp-area="" asp-controller="UIWallets" asp-action="WalletReceive" asp-route-walletId="@scheme.WalletId" text-translate="true">Receive</a>
|
<a id="WalletNav-Receive" class="nav-link @ViewData.ActivePageClass(WalletsNavPages.Receive, scheme.WalletId.ToString())" asp-area="" asp-controller="UIWallets" asp-action="WalletReceive" asp-route-walletId="@scheme.WalletId" text-translate="true">Receive</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="nav-item nav-item-sub">
|
<li class="nav-item nav-item-sub">
|
||||||
<a id="WalletNav-Settings" class="nav-link @ViewData.ActivePageClass(WalletsNavPages.Settings, scheme.WalletId.ToString()) @ViewData.ActivePageClass(StoreNavPages.OnchainSettings, categoryId)" asp-area="" asp-controller="UIStores" asp-action="WalletSettings" asp-route-cryptoCode="@scheme.WalletId.CryptoCode" asp-route-storeId="@scheme.WalletId.StoreId" text-translate="true">Settings</a>
|
<a id="WalletNav-Settings" class="nav-link @ViewData.ActivePageClass(WalletsNavPages.Settings, scheme.WalletId.ToString()) @ViewData.ActivePageClass(StoreNavPages.OnchainSettings, categoryId)" asp-area="" asp-controller="UIStores" asp-action="WalletSettings" asp-route-cryptoCode="@scheme.WalletId.CryptoCode" asp-route-storeId="@scheme.WalletId.StoreId" text-translate="true">Settings</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -408,7 +409,7 @@
|
|||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="@Model.ContactUrl" class="nav-link" id="Nav-ContactUs">
|
<a href="@Model.ContactUrl" class="nav-link" id="Nav-ContactUs">
|
||||||
<vc:icon symbol="nav-contact"/>
|
<vc:icon symbol="nav-contact"/>
|
||||||
<span text-translate="true">Contact Us</span>
|
<span text-translate="true">Contact Us</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -763,6 +763,26 @@ namespace BTCPayServer.Controllers
|
|||||||
return RedirectToAction(nameof(WalletReceive), new { walletId, returnUrl = vm.ReturnUrl });
|
return RedirectToAction(nameof(WalletReceive), new { walletId, returnUrl = vm.ReturnUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{walletId}/addresses")]
|
||||||
|
public async Task<IActionResult> ReservedAddresses(
|
||||||
|
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId)
|
||||||
|
{
|
||||||
|
var paymentMethod = GetDerivationSchemeSettings(walletId);
|
||||||
|
if (paymentMethod == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var labeledAddresses = await WalletRepository.GetReservedAddressesWithDetails(walletId);
|
||||||
|
|
||||||
|
var vm = new ReservedAddressesViewModel
|
||||||
|
{
|
||||||
|
WalletId = walletId.ToString(),
|
||||||
|
CryptoCode = walletId.CryptoCode,
|
||||||
|
Addresses = labeledAddresses
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SendFreeMoney(Cheater cheater, WalletId walletId, DerivationSchemeSettings paymentMethod)
|
private async Task SendFreeMoney(Cheater cheater, WalletId walletId, DerivationSchemeSettings paymentMethod)
|
||||||
{
|
{
|
||||||
var c = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
|
var c = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Models.WalletViewModels;
|
||||||
|
|
||||||
|
public class ReservedAddressesViewModel
|
||||||
|
{
|
||||||
|
public string WalletId { get; set; }
|
||||||
|
public string CryptoCode { get; set; }
|
||||||
|
public List<ReservedAddress> Addresses { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReservedAddress
|
||||||
|
{
|
||||||
|
public string Address { get; set; }
|
||||||
|
public List<TransactionTagModel> Labels { get; set; } = new();
|
||||||
|
public DateTimeOffset? ReservedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Extensions;
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Models.WalletViewModels;
|
||||||
using BTCPayServer.Services.Wallets;
|
using BTCPayServer.Services.Wallets;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -299,6 +301,80 @@ namespace BTCPayServer.Services
|
|||||||
.Select(FormatToLabel).ToArray();
|
.Select(FormatToLabel).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<ReservedAddress>> GetReservedAddressesWithDetails(WalletId walletId)
|
||||||
|
{
|
||||||
|
await using var ctx = _ContextFactory.CreateContext();
|
||||||
|
await using var conn = ctx.Database.GetDbConnection();
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
const string sql = """
|
||||||
|
SELECT
|
||||||
|
w."Id",
|
||||||
|
w."Data"->>'reservedAt' AS "ReservedAt",
|
||||||
|
l."Id" AS "LabelId",
|
||||||
|
l."Data"->>'color' AS "LabelColor"
|
||||||
|
FROM "WalletObjects" w
|
||||||
|
LEFT JOIN "WalletObjectLinks" n
|
||||||
|
ON n."AType" = 'address'
|
||||||
|
AND n."AId" = w."Id"
|
||||||
|
AND n."WalletId" = w."WalletId"
|
||||||
|
LEFT JOIN "WalletObjects" l
|
||||||
|
ON l."Id" = n."BId"
|
||||||
|
AND l."Type" = 'label'
|
||||||
|
AND l."WalletId" = w."WalletId"
|
||||||
|
WHERE w."WalletId" = @WalletId
|
||||||
|
AND w."Type" = 'address'
|
||||||
|
AND w."Data"->>'generatedBy' = 'receive'
|
||||||
|
""";
|
||||||
|
|
||||||
|
var parameters = new DynamicParameters();
|
||||||
|
parameters.Add("WalletId", walletId.ToString(), DbType.String);
|
||||||
|
|
||||||
|
var rows = await conn.QueryAsync(sql, parameters);
|
||||||
|
|
||||||
|
var addressesById = new Dictionary<string, ReservedAddress>();
|
||||||
|
var labelTrackers = new Dictionary<string, HashSet<string>>();
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
string id = row.Id;
|
||||||
|
|
||||||
|
if (!addressesById.TryGetValue(id, out var addr))
|
||||||
|
{
|
||||||
|
DateTimeOffset? reservedAt = null;
|
||||||
|
if (DateTimeOffset.TryParse(row.ReservedAt, out DateTimeOffset parsed))
|
||||||
|
reservedAt = parsed;
|
||||||
|
|
||||||
|
addr = new ReservedAddress
|
||||||
|
{
|
||||||
|
Address = id,
|
||||||
|
ReservedAt = reservedAt,
|
||||||
|
Labels = new List<TransactionTagModel>()
|
||||||
|
};
|
||||||
|
addressesById[id] = addr;
|
||||||
|
labelTrackers[id] = new HashSet<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.LabelId == null) continue;
|
||||||
|
|
||||||
|
string labelId = row.LabelId;
|
||||||
|
if (!labelTrackers[id].Add(labelId)) continue;
|
||||||
|
|
||||||
|
string color = row.LabelColor ?? ColorPalette.Default.DeterministicColor(labelId);
|
||||||
|
|
||||||
|
addr.Labels.Add(new TransactionTagModel
|
||||||
|
{
|
||||||
|
Text = labelId,
|
||||||
|
Color = color,
|
||||||
|
TextColor = ColorPalette.Default.TextColor(color)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return addressesById.Values
|
||||||
|
.OrderByDescending(a => a.ReservedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private (string Label, string Color) FormatToLabel(WalletObjectData o)
|
private (string Label, string Color) FormatToLabel(WalletObjectData o)
|
||||||
{
|
{
|
||||||
return o.Data is null
|
return o.Data is null
|
||||||
|
|||||||
@@ -117,12 +117,21 @@ namespace BTCPayServer.Services.Wallets
|
|||||||
await _Client.TrackAsync(derivationStrategy).ConfigureAwait(false);
|
await _Client.TrackAsync(derivationStrategy).ConfigureAwait(false);
|
||||||
pathInfo = await _Client.GetUnusedAsync(derivationStrategy, DerivationFeature.Deposit, 0, true).ConfigureAwait(false);
|
pathInfo = await _Client.GetUnusedAsync(derivationStrategy, DerivationFeature.Deposit, 0, true).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
if (storeId != null)
|
|
||||||
|
if (storeId == null) return pathInfo;
|
||||||
|
|
||||||
|
var metadata = new JObject
|
||||||
{
|
{
|
||||||
await WalletRepository.EnsureWalletObject(
|
["generatedBy"] = generatedBy
|
||||||
new WalletObjectId(new WalletId(storeId, Network.CryptoCode), WalletObjectData.Types.Address, pathInfo.Address.ToString()),
|
};
|
||||||
new JObject() { ["generatedBy"] = generatedBy });
|
|
||||||
|
if (generatedBy == "receive")
|
||||||
|
{
|
||||||
|
metadata["reservedAt"] = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await WalletRepository.EnsureWalletObject(
|
||||||
|
new WalletObjectId(new WalletId(storeId, Network.CryptoCode), WalletObjectData.Types.Address, pathInfo.Address.ToString()), metadata);
|
||||||
return pathInfo;
|
return pathInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="btn btn-secondary" asp-controller="UIWallets" asp-action="WalletLabels"
|
<a id="manage-wallet-labels-button" class="btn btn-secondary" asp-controller="UIWallets" asp-action="WalletLabels"
|
||||||
asp-route-walletId="@Model.WalletId" text-translate="true">Manage labels</a>
|
asp-route-walletId="@Model.WalletId" text-translate="true">Manage labels</a>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" id="SaveWalletSettings" form="walletSettingsForm" text-translate="true">Save Wallet Settings</button>
|
<button type="submit" class="btn btn-primary" id="SaveWalletSettings" form="walletSettingsForm" text-translate="true">Save Wallet Settings</button>
|
||||||
|
|||||||
179
BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml
Normal file
179
BTCPayServer/Views/UIWallets/ReservedAddresses.cshtml
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
@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>
|
||||||
|
}
|
||||||
@@ -33,9 +33,15 @@
|
|||||||
<span>@label.Label</span>
|
<span>@label.Label</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<form method="post" asp-action="RemoveWalletLabel" asp-route-walletId="@Model.WalletId" asp-route-id="@label.Label">
|
<a class="btn btn-link p-0 me-3"
|
||||||
<button class="btn btn-link btn-delete p-0" type="submit" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@StringLocalizer["The label {0} will be removed from this wallet and its associated transactions.", Html.Encode(label.Label)]" data-confirm-input="@StringLocalizer["DELETE"]" text-translate="true">Remove</button>
|
href="@Url.Action("ReservedAddresses", "UIWallets", new { walletId = Model.WalletId.ToString(), filter = label.Label })"
|
||||||
|
title="View Reserved Addresses with this label">
|
||||||
|
Addresses
|
||||||
|
</a>
|
||||||
|
<form method="post" asp-action="RemoveWalletLabel" asp-route-walletId="@Model.WalletId" asp-route-id="@label.Label" class="d-inline">
|
||||||
|
<button class="btn btn-link btn-delete p-0 me-3" type="submit" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@StringLocalizer["The label {0} will be removed from this wallet and its associated transactions.", Html.Encode(label.Label)]" data-confirm-input="@StringLocalizer["DELETE"]" text-translate="true">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -98,6 +98,15 @@
|
|||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button type="submit" name="command" value="generate-new-address" class="btn btn-primary w-100" text-translate="true">Generate another address</button>
|
<button type="submit" name="command" value="generate-new-address" class="btn btn-primary w-100" text-translate="true">Generate another address</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="payment-box mt-3">
|
||||||
|
<a class="btn btn-outline-info w-100"
|
||||||
|
id="reserved-addresses-button"
|
||||||
|
asp-controller="UIWallets" asp-action="ReservedAddresses" asp-route-walletId="@walletId">
|
||||||
|
Reserved Addresses
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (env.CheatMode)
|
@if (env.CheatMode)
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ async function initLabelManager (elementId) {
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
async onChange (values) {
|
async onChange (values) {
|
||||||
|
const labels = Array.isArray(values) ? values : values.split(',');
|
||||||
|
|
||||||
|
element.dispatchEvent(new CustomEvent("labelmanager:changed", {
|
||||||
|
detail: {
|
||||||
|
walletObjectId,
|
||||||
|
labels: labels
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
const selectElementI = selectElement ? document.getElementById(selectElement) : null;
|
const selectElementI = selectElement ? document.getElementById(selectElement) : null;
|
||||||
if (selectElementI){
|
if (selectElementI){
|
||||||
while (selectElementI.options.length > 0) {
|
while (selectElementI.options.length > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user