Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wallet transactions: Add label manager #4796

Merged
6 changes: 3 additions & 3 deletions BTCPayServer.Tests/SeleniumTests.cs
Expand Up @@ -1374,11 +1374,11 @@ public async Task CanManageWallet()
// Can add a label?
await TestUtils.EventuallyAsync(async () =>
{
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).Click();
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).Click();
await Task.Delay(500);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).SendKeys("test-label" + Keys.Enter);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).SendKeys("test-label" + Keys.Enter);
await Task.Delay(500);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).SendKeys("label2" + Keys.Enter);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).SendKeys("label2" + Keys.Enter);
});

TestUtils.Eventually(() =>
Expand Down
2 changes: 1 addition & 1 deletion BTCPayServer/ColorPalette.cs
Expand Up @@ -19,7 +19,7 @@ public string TextColor(string bgColor)
var bg = ColorTranslator.FromHtml(bgColor);
int bgDelta = Convert.ToInt32((bg.R * 0.299) + (bg.G * 0.587) + (bg.B * 0.114));
Color color = (255 - bgDelta < nThreshold) ? Color.Black : Color.White;
return ColorTranslator.ToHtml(color);
return ColorTranslator.ToHtml(color).ToLowerInvariant();
}
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
public static readonly ColorPalette Default = new ColorPalette(new string[] {
Expand Down
113 changes: 15 additions & 98 deletions BTCPayServer/Components/LabelManager/Default.cshtml
@@ -1,104 +1,21 @@
@using NBitcoin.DataEncoders
@using NBitcoin
@using BTCPayServer.Abstractions.TagHelpers
@model BTCPayServer.Components.LabelManager.LabelViewModel
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@{
var commonCall = Model.ObjectId.Type + Model.ObjectId.Id;
var elementId = "a" + Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
var fetchUrl = Url.Action("GetLabels", "UIWallets", new {
walletId = Model.WalletObjectId.WalletId,
excludeTypes = Safe.Json(Model.ExcludeTypes)
});
var updateUrl = Url.Action("UpdateLabels", "UIWallets", new {
walletId = Model.WalletObjectId.WalletId
});
}

<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" rel="stylesheet">
<script src="~/vendor/tom-select/tom-select.complete.min.js"></script>
<script>
const updateUrl = @Safe.Json(Url.Action("UpdateLabels", "UIWallets", new {
Model.ObjectId.WalletId
}));
const getUrl = @Safe.Json(@Url.Action("GetLabels", "UIWallets", new {
walletId = Model.ObjectId.WalletId,
excludeTypes = true
}));
const commonCall = @Safe.Json(commonCall);
const elementId = @Safe.Json(elementId);
if (!window[commonCall]) {
window[commonCall] = fetch(getUrl, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
}).then(response => {
return response.json();
});
}

document.addEventListener("DOMContentLoaded", async () => {
const element = document.querySelector(`#${elementId}`);

if (element) {
const labelsFetchTask = await window[commonCall];
const config = {
create: true,
items: @Safe.Json(Model.SelectedLabels),
options: labelsFetchTask,
valueField: "label",
labelField: "label",
searchField: "label",
allowEmptyOption: false,
closeAfterSelect: false,
persist: true,
render: {
option: function(data, escape) {
return `<div ${data.color? `style='background-color:${data.color}; color:${data.textColor}'`: ""}>${escape(data.label)}</div>`;
},
item: function(data, escape) {
return `<div ${data.color? `style='background-color:${data.color}; color:${data.textColor}'`: ""}>${escape(data.label)}</div>`;
}
},
onItemAdd: (val) => {
window[commonCall] = window[commonCall].then(labels => {
return [...labels, { label: val }]
});

document.dispatchEvent(new CustomEvent(`${commonCall}-option-added`, {
detail: val
}));
},
onChange: async (values) => {
select.lock();
try {
const response = await fetch(updateUrl, {
method: "POST",
credentials: "include",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
address: @Safe.Json(Model.ObjectId.Id),
labels: select.items
})
});
if (!response.ok) {
throw new Error('Network response was not OK');
}
} catch (error) {
console.error('There has been a problem with your fetch operation:', error);
} finally {
select.unlock();
}
}
};
const select = new TomSelect(element, config);

document.addEventListener(`${commonCall}-option-added`, evt => {
if (!(evt.detail in select.options)) {
select.addOption({
label: evt.detail
})
}
})
}
})
</script>

<input id="@elementId" placeholder="Select labels to associate with this object" autocomplete="off" class="form-control label-manager"/>
<input id="@elementId" placeholder="Select labels" autocomplete="off" value="@string.Join(",", Model.SelectedLabels)"
class="only-for-js form-control label-manager ts-wrapper @(Model.DisplayInline ? "ts-inline" : "")"
data-fetch-url="@fetchUrl"
data-update-url="@updateUrl"
data-wallet-id="@Model.WalletObjectId.WalletId"
data-wallet-object-id="@Model.WalletObjectId.Id"
data-wallet-object-type="@Model.WalletObjectId.Type"
data-labels='@Safe.Json(Model.RichLabelInfo)' />
16 changes: 13 additions & 3 deletions BTCPayServer/Components/LabelManager/LabelManager.cs
@@ -1,18 +1,28 @@
using System.Collections.Generic;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc;

namespace BTCPayServer.Components.LabelManager
{
public class LabelManager : ViewComponent
{
public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels)
public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels, bool excludeTypes = true, bool displayInline = false, Dictionary<string, RichLabelInfo> richLabelInfo = null)
{
var vm = new LabelViewModel
{
ObjectId = walletObjectId,
SelectedLabels = selectedLabels
ExcludeTypes = excludeTypes,
WalletObjectId = walletObjectId,
SelectedLabels = selectedLabels,
DisplayInline = displayInline,
RichLabelInfo = richLabelInfo
};
return View(vm);
}
}

public class RichLabelInfo
{
public string Link { get; set; }
public string Tooltip { get; set; }
}
}
6 changes: 5 additions & 1 deletion BTCPayServer/Components/LabelManager/LabelViewModel.cs
@@ -1,10 +1,14 @@
using System.Collections.Generic;
using BTCPayServer.Services;

namespace BTCPayServer.Components.LabelManager
{
public class LabelViewModel
{
public string[] SelectedLabels { get; set; }
public WalletObjectId ObjectId { get; set; }
public WalletObjectId WalletObjectId { get; set; }
public bool ExcludeTypes { get; set; }
public bool DisplayInline { get; set; }
public Dictionary<string, RichLabelInfo> RichLabelInfo { get; set; }
}
}
43 changes: 27 additions & 16 deletions BTCPayServer/Controllers/UIWalletsController.cs
Expand Up @@ -235,8 +235,7 @@ public async Task<IActionResult> ListWallets()
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
model.Labels.AddRange(
(await WalletRepository.GetWalletLabels(walletId))
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color)))
);
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));

if (labelFilter != null)
{
Expand Down Expand Up @@ -1324,18 +1323,21 @@ await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)

public class UpdateLabelsRequest
{
public string? Address { get; set; }
public string? Id { get; set; }
public string? Type { get; set; }
public string[]? Labels { get; set; }
}

[HttpPost("{walletId}/update-labels")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> UpdateLabels([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, [FromBody] UpdateLabelsRequest request)
public async Task<IActionResult> UpdateLabels(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
[FromBody] UpdateLabelsRequest request)
{
if (string.IsNullOrEmpty(request.Address) || request.Labels is null)
if (string.IsNullOrEmpty(request.Type) || string.IsNullOrEmpty(request.Id) || request.Labels is null)
return BadRequest();

var objid = new WalletObjectId(walletId, WalletObjectData.Types.Address, request.Address);
var objid = new WalletObjectId(walletId, request.Type, request.Id);
var obj = await WalletRepository.GetWalletObject(objid);
if (obj is null)
{
Expand All @@ -1353,17 +1355,26 @@ public async Task<IActionResult> UpdateLabels([ModelBinder(typeof(WalletIdModelB

[HttpGet("{walletId}/labels")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> GetLabels( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, bool excludeTypes)
public async Task<IActionResult> GetLabels(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
bool excludeTypes,
string? type = null,
string? id = null)
{

return Ok(( await WalletRepository.GetWalletLabels(walletId))
.Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
.Select(tuple => new
{
label = tuple.Label,
color = tuple.Color,
textColor = ColorPalette.Default.TextColor(tuple.Color)
}));
var walletObjectId = !string.IsNullOrEmpty(type) && !string.IsNullOrEmpty(id)
? new WalletObjectId(walletId, type, id)
: null;
var labels = walletObjectId == null
? await WalletRepository.GetWalletLabels(walletId)
: await WalletRepository.GetWalletLabels(walletObjectId);
Comment on lines +1376 to +1381
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turned out I didn't need this, but this makes it more flexible so that one can request either labels for the whole wallet or individual wallet objects.

return Ok(labels
.Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
.Select(tuple => new
{
label = tuple.Label,
color = tuple.Color,
textColor = ColorPalette.Default.TextColor(tuple.Color)
}));
}

private string GetImage(PaymentMethodId paymentMethodId, BTCPayNetwork network)
Expand Down
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Services.Labels;

namespace BTCPayServer.Models.WalletViewModels
{
Expand All @@ -15,10 +14,10 @@ public class TransactionViewModel
public string Link { get; set; }
public bool Positive { get; set; }
public string Balance { get; set; }
public HashSet<TransactionTagModel> Tags { get; set; } = new HashSet<TransactionTagModel>();
public HashSet<TransactionTagModel> Tags { get; set; } = new ();
}
public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new HashSet<(string Text, string Color, string TextColor)>();
public List<TransactionViewModel> Transactions { get; set; } = new List<TransactionViewModel>();
public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new ();
public List<TransactionViewModel> Transactions { get; set; } = new ();
public override int CurrentPageCount => Transactions.Count;
public string CryptoCode { get; set; }
}
Expand Down
33 changes: 20 additions & 13 deletions BTCPayServer/Services/WalletRepository.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
Expand Down Expand Up @@ -309,21 +310,27 @@ public WalletTransactionInfo Merge(params WalletTransactionInfo[] infos)
#nullable enable

public async Task<(string Label, string Color)[]> GetWalletLabels(WalletId walletId)
{
return await GetWalletLabels(w =>
w.WalletId == walletId.ToString() &&
w.Type == WalletObjectData.Types.Label);
}

public async Task<(string Label, string Color)[]> GetWalletLabels(WalletObjectId objectId)
{
return await GetWalletLabels(w =>
w.WalletId == objectId.WalletId.ToString() &&
w.Type == objectId.Type &&
w.Id == objectId.Id);
}

private async Task<(string Label, string Color)[]> GetWalletLabels(Expression<Func<WalletObjectData, bool>> predicate)
{
await using var ctx = _ContextFactory.CreateContext();
return (await
ctx.WalletObjects.AsNoTracking().Where(w => w.WalletId == walletId.ToString() && w.Type == WalletObjectData.Types.Label)
.ToArrayAsync())
.Select(o =>
{
if (o.Data is null)
{
return (o.Id, ColorPalette.Default.DeterministicColor(o.Id));
}
return (o.Id,
JObject.Parse(o.Data)["color"]?.Value<string>() ??
ColorPalette.Default.DeterministicColor(o.Id));
}).ToArray();
return (await ctx.WalletObjects.AsNoTracking().Where(predicate).ToArrayAsync())
.Select(o => o.Data is null
? (o.Id, ColorPalette.Default.DeterministicColor(o.Id))
: (o.Id, JObject.Parse(o.Data)["color"]?.Value<string>() ?? ColorPalette.Default.DeterministicColor(o.Id))).ToArray();
}

public async Task<bool> RemoveWalletObjects(WalletObjectId walletObjectId)
Expand Down
2 changes: 2 additions & 0 deletions BTCPayServer/Views/UIWallets/WalletReceive.cshtml
Expand Up @@ -13,6 +13,8 @@
@section PageHeadContent
{
<link href="~/main/qrcode.css" rel="stylesheet" asp-append-version="true"/>
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
}

@section Navbar {
Expand Down
6 changes: 4 additions & 2 deletions BTCPayServer/Views/UIWallets/WalletTransactions.cshtml
Expand Up @@ -9,6 +9,8 @@
}

@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>
.smMaxWidth {
max-width: 125px;
Expand Down Expand Up @@ -45,7 +47,6 @@
@section PageFootContent {
@*Without async, somehow selenium do not manage to click on links in this page*@
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>

@* Custom Range Modal *@
<script>
let observer = null;
Expand Down Expand Up @@ -92,7 +93,7 @@

if (response.ok) {
const html = await response.text();
$list.innerHTML += html;
$list.insertAdjacentHTML('beforeend', html);
Comment on lines -95 to +96
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This did the trick on not removing event listeners from existing TomSelect instances. @Kukks

skip = skipNext;

if ($loadMore) {
Expand Down Expand Up @@ -122,6 +123,7 @@

$indicator.classList.add('d-none');
formatDateTimes(document.getElementById('switchTimeFormat').dataset.mode);
initLabelManagers();
dennisreimann marked this conversation as resolved.
Show resolved Hide resolved
}
</script>
}
Expand Down