Adds table (wallet transactions) column controls (hide/show/re-order) (#7474)
What changed, and why it matters
This commit adds a user-facing preference feature to BTCPay Server's wallet transactions table: a dropdown that lets users show, hide, and reorder columns. Preferences are saved in the browser's localStorage. The change is a routine UI enhancement and does not appear to be a security fix. There is no indication from the commit that the vendor considers it security-relevant.
No security action required. Treat as a normal feature commit. If reviewing for quality, verify that localStorage values are correctly scoped per store and that the UI gracefully handles missing or malformed saved preferences.
Security signals we found
No security-relevant keywords in commit title or message
Pure client-side UI feature using localStorage
No changes to authentication, authorization, or input processing
No XSS-sensitive patterns observed (textContent used for labels, no innerHTML with user input)
No external references or advisories supplied
Evidence from the diff
The patch introduces a new JavaScript module (wallet-transactions-columns.js), CSS, and Razor view updates to allow per-store column customization for the wallet transactions table. It uses localStorage scoped by store ID to persist order and visibility. The code reads column metadata from data-col/data-col-label attributes, builds a control list, and reorders/hides matching header and body cells via DOM manipulation. It includes safeguards such as preventing all columns from being hidden and graceful degradation when storage is unavailable. No server-side authorization, input validation, or data-handling logic is changed.
Changed components
BTCPayServer/Components/DateColumnBTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtmlBTCPayServer/Plugins/Wallets/Views/UIWallets/_WalletTransactionsList.cshtmlBTCPayServer/wwwroot/js/wallet/wallet-transactions-columns.jsBTCPayServer/wwwroot/main/site.cssBTCPayServer/wwwroot/img/icon-sprite.svgInspect captured patch +422 / −15
diff --git a/BTCPayServer/Components/DateColumn/DateColumn.cs b/BTCPayServer/Components/DateColumn/DateColumn.cs
index 7ac1353..fab2936 100644
--- a/BTCPayServer/Components/DateColumn/DateColumn.cs
+++ b/BTCPayServer/Components/DateColumn/DateColumn.cs
@@ -8,11 +8,13 @@ public class DateColumn(IStringLocalizer stringLocalizer) : ViewComponent
public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
public string? ColumnName { get; set; }
- public IViewComponentResult Invoke(string? columnName = null)
+ public string? ColumnKey { get; set; }
+ public IViewComponentResult Invoke(string? columnName = null, string? columnKey = null)
{
if (columnName is null)
columnName = StringLocalizer["Date"];
ColumnName = columnName;
+ ColumnKey = columnKey;
return View(this);
}
}
diff --git a/BTCPayServer/Components/DateColumn/Default.cshtml b/BTCPayServer/Components/DateColumn/Default.cshtml
index db50fdf..e726e8a 100644
--- a/BTCPayServer/Components/DateColumn/Default.cshtml
+++ b/BTCPayServer/Components/DateColumn/Default.cshtml
@@ -1,6 +1,6 @@
@model BTCPayServer.Components.DateColumn.DateColumn
-<th class="date-col">
+<th class="date-col" data-col="@Model.ColumnKey" data-col-label="@(Model.ColumnKey is not null ? Model.ColumnName : null)">
<div>
<span>@Model.ColumnName</span>
<button type="button" class="btn btn-link p-0 ms-2 switch-time-format only-for-js" title="@StringLocalizer["Switch date format"]">
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
index 95f9bd1..3afbaaf 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIWallets/WalletTransactions.cshtml
@@ -96,6 +96,7 @@
@section PageFootContent {
@*Script must be async to ensure proper page loading*@
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
+ <script src="~/js/wallet/wallet-transactions-columns.js" asp-append-version="true"></script>
<script>
const $actions = document.getElementById('ListActions');
const $transactions = document.getElementById('WalletTransactions');
@@ -158,6 +159,7 @@
if (switchTime)
formatDateTimes(switchTime.dataset.mode);
initLabelManagers();
+ if (window.WalletTxColumns) window.WalletTxColumns.apply();
}
const observer = new IntersectionObserver(async entries => {
@@ -218,7 +220,8 @@
</div>
<partial name="_StatusMessage" />
-<form id="WalletTransactionsSearch" class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 col-xxl-8" asp-action="WalletTransactions" asp-route-walletId="@walletId" method="get">
+<div class="d-flex flex-wrap align-items-center gap-3 mb-4" id="WalletTransactionsControls">
+<form id="WalletTransactionsSearch" class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 col-xxl-8" asp-action="WalletTransactions" asp-route-walletId="@walletId" method="get">
<input id="Count" name="Count" type="hidden" value="@Model.Count" />
<vc:search-string-input search-string="Model.Search" placeholder="@StringLocalizer["Search transactions..."]"></vc:search-string-input>
@@ -249,6 +252,19 @@
<vc:clear-all-filters search="Model.Search"></vc:clear-all-filters>
</form>
+ <div class="dropdown wallet-columns ms-auto only-for-js" id="ColumnsDropdown">
+ <button class="btn btn-secondary" type="button" id="ColumnsDropdownToggle" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false" title="@StringLocalizer["Columns"]" aria-label="@StringLocalizer["Columns"]">
+ <vc:icon symbol="filter" />
+ </button>
+ <div class="dropdown-menu dropdown-menu-end wallet-columns__menu" aria-labelledby="ColumnsDropdownToggle">
+ <div class="wallet-columns__hint text-secondary" text-translate="true">Check to show/hide · drag to reorder</div>
+ <ul class="wallet-columns__list list-unstyled mb-0" id="ColumnsList" data-store-id="@storeId" data-move-label="@StringLocalizer["Move {0}"]"></ul>
+ <div class="dropdown-divider"></div>
+ <button type="button" class="btn btn-link wallet-columns__reset" id="ColumnsReset" text-translate="true">Reset to default</button>
+ </div>
+ </div>
+</div>
+
@if (Model.PendingTransactions?.Any() == true)
{
var showMultisigColumns = Model.PendingTransactions.Any(p =>
@@ -325,14 +341,14 @@
<th class="only-for-js mass-action-select-col" permission="@WalletPolicies.CanCreateWalletTransactions">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
- <vc:date-column></vc:date-column>
- <th text-translate="true" style="min-width:125px">Label</th>
- <th text-translate="true">Transaction</th>
- <th text-translate="true" class="amount-col">Amount</th>
- <th text-translate="true" class="fee-col">Transaction fee</th>
+ <vc:date-column column-key="date"></vc:date-column>
+ <th text-translate="true" style="min-width:125px" data-col="label" data-col-label="@StringLocalizer["Label"]">Label</th>
+ <th text-translate="true" data-col="transaction" data-col-label="@StringLocalizer["Transaction"]">Transaction</th>
+ <th text-translate="true" class="amount-col" data-col="amount" data-col-label="@StringLocalizer["Amount"]">Amount</th>
+ <th text-translate="true" class="fee-col" data-col="fee" data-col-label="@StringLocalizer["Transaction fee"]">Transaction fee</th>
@foreach (var rate in Model.Rates)
{
- <th class="rate-col"><span text-translate="true">Rate</span> <span>(@rate)</span></th>
+ <th class="rate-col" data-col="rate" data-col-label="@StringLocalizer["Rate"]"><span text-translate="true">Rate</span> <span>(@rate)</span></th>
}
<th></th>
</tr>
diff --git a/BTCPayServer/Plugins/Wallets/Views/UIWallets/_WalletTransactionsList.cshtml b/BTCPayServer/Plugins/Wallets/Views/UIWallets/_WalletTransactionsList.cshtml
index 5bfa97d..6c616a8 100644
--- a/BTCPayServer/Plugins/Wallets/Views/UIWallets/_WalletTransactionsList.cshtml
+++ b/BTCPayServer/Plugins/Wallets/Views/UIWallets/_WalletTransactionsList.cshtml
@@ -10,10 +10,10 @@
<td class="align-middle only-for-js mass-action-select-col" permission="@WalletPolicies.CanCreateWalletTransactions">
<input name="selectedTransactions" type="checkbox" class="form-check-input mass-action-select" form="WalletActions" value="@transaction.Id" />
</td>
- <td class="align-middle date-col">
+ <td class="align-middle date-col" data-col="date">
@transaction.Timestamp.ToBrowserDate()
</td>
- <td class="align-middle text-start">
+ <td class="align-middle text-start" data-col="label">
<span permission="@WalletPolicies.CanManageWalletTransactions">
<vc:label-manager
wallet-object-id="new WalletObjectId(WalletId.Parse(walletId), WalletObjectData.Types.Tx, transaction.Id)"
@@ -45,13 +45,13 @@
}
</span>
</td>
- <td>
+ <td data-col="transaction">
<vc:truncate-center text="@transaction.Id" link="@transaction.Link" classes="truncate-center-id" />
</td>
- <td class="align-middle amount-col">
+ <td class="align-middle amount-col" data-col="amount">
<span data-sensitive class="text-@(transaction.Positive ? "success" : "danger")@(transaction.IsConfirmed ? "" : " opacity-50")">@transaction.Balance</span>
</td>
- <td class="align-middle fee-col">
+ <td class="align-middle fee-col" data-col="fee">
<span data-sensitive class="@(transaction.IsConfirmed ? "" : " opacity-50")">
@if (transaction.HistoryLine is { Fee: { } fee, FeeRate: { } fr })
{
@@ -61,7 +61,7 @@
</td>
@foreach (var rate in transaction.Rates)
{
- <td class="align-middle rate-col">
+ <td class="align-middle rate-col" data-col="rate">
<span>@rate</span>
</td>
}
diff --git a/BTCPayServer/wwwroot/img/icon-sprite.svg b/BTCPayServer/wwwroot/img/icon-sprite.svg
index 9056c9c..dfc4c94 100644
--- a/BTCPayServer/wwwroot/img/icon-sprite.svg
+++ b/BTCPayServer/wwwroot/img/icon-sprite.svg
@@ -102,6 +102,7 @@
<symbol id="review-half" viewBox="0 0 24 24" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.001 4.35C12.1522 4.3499 12.3004 4.39213 12.4289 4.4719C12.5573 4.55168 12.6609 4.66581 12.7278 4.80139L14.7601 8.92118L19.3065 9.58207C19.456 9.60379 19.5965 9.6669 19.712 9.76427C19.8276 9.86164 19.9135 9.98938 19.9603 10.1331C20.007 10.2768 20.0126 10.4306 19.9765 10.5774C19.9403 10.7241 19.8639 10.8577 19.7557 10.9633L16.4664 14.1705L17.2428 18.6974C17.2684 18.8464 17.2518 18.9995 17.1949 19.1395C17.138 19.2796 17.0431 19.4009 16.9209 19.4897C16.7986 19.5786 16.654 19.6315 16.5032 19.6424C16.3525 19.6534 16.2017 19.6219 16.0679 19.5516L12.001 17.4134L7.93415 19.5516C7.80045 19.6218 7.64978 19.6533 7.49916 19.6423C7.34854 19.6314 7.20397 19.5786 7.08177 19.4899C6.95958 19.4012 6.86462 19.2801 6.80762 19.1402C6.75063 19.0004 6.73387 18.8474 6.75923 18.6985L7.53675 14.1695L4.24524 10.9633C4.13679 10.8578 4.06004 10.724 4.02371 10.5772C3.98737 10.4303 3.9929 10.2762 4.03966 10.1323C4.08642 9.98842 4.17255 9.86051 4.28828 9.76307C4.40402 9.66563 4.54473 9.60256 4.69448 9.58099L9.24082 8.92118L11.2743 4.80139C11.3412 4.66581 11.4447 4.55168 11.5732 4.4719C11.7016 4.39213 11.8498 4.3499 12.001 4.35ZM12.001 6.99033L10.5054 10.0194C10.4473 10.137 10.3615 10.2388 10.2554 10.3159C10.1493 10.393 10.0261 10.4432 9.89631 10.4622L6.55189 10.9481L8.97085 13.3066C9.06496 13.3983 9.13537 13.5114 9.17601 13.6363C9.21665 13.7613 9.22629 13.8942 9.2041 14.0237L8.63392 17.354L11.6241 15.7817C11.7404 15.7206 11.8697 15.6887 12.001 15.6887C12.1323 15.6887 12.2617 15.7206 12.3779 15.7817L15.3692 17.354L14.7969 14.0237C14.7747 13.8942 14.7843 13.7613 14.825 13.6363C14.8656 13.5114 14.936 13.3983 15.0301 13.3066L17.4491 10.9492L14.1057 10.4633C13.9759 10.4443 13.8527 10.3941 13.7466 10.317C13.6405 10.2398 13.5548 10.1381 13.4967 10.0205L12.001 6.98925V6.99033Z" fill="currentColor"/><path d="M7.93415 19.5516L12.001 17.4134V4.35C11.8498 4.3499 11.7016 4.39213 11.5732 4.4719C11.4447 4.55168 11.3412 4.66581 11.2743 4.80139L9.24082 8.92118L4.69448 9.58099C4.54473 9.60256 4.40402 9.66563 4.28828 9.76307C4.17255 9.86051 4.08642 9.98842 4.03966 10.1323C3.9929 10.2762 3.98737 10.4303 4.02371 10.5772C4.06004 10.724 4.13679 10.8578 4.24524 10.9633L7.53675 14.1695L6.75923 18.6985C6.73387 18.8474 6.75063 19.0004 6.80762 19.1402C6.86462 19.2801 6.95958 19.4012 7.08177 19.4899C7.20397 19.5786 7.34854 19.6314 7.49916 19.6423C7.64978 19.6533 7.80045 19.6218 7.93415 19.5516Z" fill="currentColor"/></symbol>
<symbol id="scan-qr" viewBox="0 0 24 24" fill="none"><path d="M4 5.77778V9.33333H5.77778V5.77778H9.33333V4H5.77778C4.8 4 4 4.8 4 5.77778ZM5.77778 14.6667H4V18.2222C4 19.2 4.8 20 5.77778 20H9.33333V18.2222H5.77778V14.6667ZM18.2222 18.2222H14.6667V20H18.2222C19.2 20 20 19.2 20 18.2222V14.6667H18.2222V18.2222ZM18.2222 4H14.6667V5.77778H18.2222V9.33333H20V5.77778C20 4.8 19.2 4 18.2222 4Z" fill="currentColor"/></symbol>
<symbol id="settings" viewBox="0 0 24 24" fill="none"><path d="M9.81672 6.15385L10.3459 4.78769C10.4352 4.55639 10.5922 4.35743 10.7965 4.21688C11.0007 4.07632 11.2426 4.00073 11.4906 4H12.4998C12.7477 4.00073 12.9896 4.07632 13.1939 4.21688C13.3981 4.35743 13.5552 4.55639 13.6444 4.78769L14.1736 6.15385L15.9706 7.18769L17.4229 6.96615C17.6647 6.93333 17.9108 6.97314 18.13 7.08052C18.3491 7.1879 18.5314 7.35801 18.6536 7.56923L19.1459 8.43077C19.2721 8.64535 19.3302 8.89314 19.3126 9.14144C19.2951 9.38974 19.2026 9.62687 19.0475 9.82154L18.149 10.9662V13.0338L19.0721 14.1785C19.2272 14.3731 19.3197 14.6103 19.3373 14.8586C19.3548 15.1069 19.2967 15.3546 19.1706 15.5692L18.6783 16.4308C18.556 16.642 18.3737 16.8121 18.1546 16.9195C17.9354 17.0269 17.6893 17.0667 17.4475 17.0338L15.9952 16.8123L14.1983 17.8462L13.669 19.2123C13.5798 19.4436 13.4227 19.6426 13.2185 19.7831C13.0143 19.9237 12.7723 19.9993 12.5244 20H11.4906C11.2426 19.9993 11.0007 19.9237 10.7965 19.7831C10.5922 19.6426 10.4352 19.4436 10.3459 19.2123L9.81672 17.8462L8.01979 16.8123L6.56749 17.0338C6.32566 17.0667 6.07954 17.0269 5.86039 16.9195C5.64124 16.8121 5.45896 16.642 5.33672 16.4308L4.84441 15.5692C4.71826 15.3546 4.66013 15.1069 4.67771 14.8586C4.69529 14.6103 4.78774 14.3731 4.94287 14.1785L5.84133 13.0338V10.9662L4.91826 9.82154C4.76313 9.62687 4.67068 9.38974 4.6531 9.14144C4.63552 8.89314 4.69364 8.64535 4.81979 8.43077L5.3121 7.56923C5.43435 7.35801 5.61662 7.1879 5.83577 7.08052C6.05492 6.97314 6.30105 6.93333 6.54287 6.96615L7.99518 7.18769L9.81672 6.15385ZM9.53364 12C9.53364 12.4868 9.67801 12.9628 9.94848 13.3676C10.219 13.7724 10.6034 14.0879 11.0532 14.2742C11.503 14.4605 11.9979 14.5092 12.4754 14.4142C12.9529 14.3193 13.3915 14.0848 13.7357 13.7406C14.08 13.3963 14.3144 12.9577 14.4094 12.4802C14.5044 12.0027 14.4557 11.5078 14.2693 11.058C14.083 10.6082 13.7675 10.2238 13.3627 9.95331C12.9579 9.68283 12.482 9.53846 11.9952 9.53846C11.3423 9.53846 10.7162 9.7978 10.2546 10.2594C9.79298 10.7211 9.53364 11.3472 9.53364 12V12Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
+<symbol id="filter" viewBox="0 0 24 24" fill="none"><path d="M20 5.6001C20 5.04005 19.9996 4.75981 19.8906 4.5459C19.7948 4.35774 19.6423 4.20487 19.4542 4.10899C19.2403 4 18.9597 4 18.3996 4H5.59961C5.03956 4 4.75981 4 4.5459 4.10899C4.35774 4.20487 4.20487 4.35774 4.10899 4.5459C4 4.75981 4 5.04005 4 5.6001V6.33736C4 6.58195 4 6.70433 4.02763 6.81942C4.05213 6.92146 4.09263 7.01893 4.14746 7.1084C4.20928 7.20928 4.29591 7.29591 4.46875 7.46875L9.53149 12.5315C9.70443 12.7044 9.79044 12.7904 9.85228 12.8914C9.90711 12.9808 9.94816 13.0786 9.97266 13.1807C10 13.2946 10 13.4155 10 13.6552V18.411C10 19.2682 10 19.6971 10.1805 19.9552C10.3382 20.1806 10.5814 20.331 10.8535 20.3712C11.1651 20.4172 11.5487 20.2257 12.3154 19.8424L13.1154 19.4424C13.4365 19.2819 13.5966 19.2013 13.7139 19.0815C13.8176 18.9756 13.897 18.8485 13.9453 18.7084C14 18.5499 14 18.37 14 18.011V13.6626C14 13.418 14 13.2958 14.0276 13.1807C14.0521 13.0786 14.0926 12.9808 14.1475 12.8914C14.2089 12.7911 14.2947 12.7053 14.4653 12.5347L14.4688 12.5315L19.5315 7.46875C19.7044 7.2958 19.7904 7.20932 19.8523 7.1084C19.9071 7.01893 19.9482 6.92146 19.9727 6.81942C20 6.70551 20 6.58444 20 6.3448V5.6001Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="social-github" viewBox="0 0 25 24" fill="none"><path clip-rule="evenodd" d="M12.75.3c-6.6 0-12 5.4-12 12 0 5.325 3.45 9.825 8.175 11.4.6.075.825-.225.825-.6v-2.025C6.375 21.825 5.7 19.5 5.7 19.5c-.525-1.35-1.35-1.725-1.35-1.725-1.125-.75.075-.75.075-.75 1.2.075 1.875 1.2 1.875 1.2 1.05 1.8 2.775 1.275 3.525.975a2.59 2.59 0 0 1 .75-1.575c-2.7-.3-5.475-1.35-5.475-5.925 0-1.275.45-2.4 1.2-3.225-.15-.3-.525-1.5.15-3.15 0 0 .975-.3 3.3 1.2.975-.3 1.95-.375 3-.375s2.025.15 3 .375c2.325-1.575 3.3-1.275 3.3-1.275.675 1.65.225 2.85.15 3.15.75.825 1.2 1.875 1.2 3.225 0 4.575-2.775 5.625-5.475 5.925.45.375.825 1.125.825 2.25v3.3c0 .3.225.675.825.6a12.015 12.015 0 0 0 8.175-11.4c0-6.6-5.4-12-12-12z" fill="currentColor" fill-rule="evenodd"/></symbol>
<symbol id="social-mattermost" viewBox="0 0 206 206" fill="none"><path fill="currentColor" d="m163.012 19.596 1.082 21.794c17.667 19.519 24.641 47.161 15.846 73.14-13.129 38.782-56.419 59.169-96.693 45.535-40.272-13.633-62.278-56.124-49.15-94.905 8.825-26.066 31.275-43.822 57.276-48.524L105.422.038C61.592-1.15 20.242 26.056 5.448 69.76c-18.178 53.697 10.616 111.963 64.314 130.142 53.698 18.178 111.964-10.617 130.143-64.315 14.77-43.633-1.474-90.283-36.893-115.99"/><path fill="currentColor" d="m137.097 53.436-.596-17.531-.404-15.189s.084-7.322-.17-9.043a2.776 2.776 0 0 0-.305-.914l-.05-.109-.06-.094a2.378 2.378 0 0 0-1.293-1.07 2.382 2.382 0 0 0-1.714.078l-.033.014-.18.092a2.821 2.821 0 0 0-.75.518c-1.25 1.212-5.63 7.08-5.63 7.08l-9.547 11.82-11.123 13.563-19.098 23.75s-8.763 10.938-6.827 24.4c1.937 13.464 11.946 20.022 19.71 22.65 7.765 2.63 19.7 3.5 29.417-6.019 9.716-9.518 9.397-23.53 9.397-23.53l-.744-30.466z"/></symbol>
<symbol id="social-nostr" viewBox="0 0 533.31 533.31" fill="none"><path d="M266.655 2.542C120.789 2.542 2.542 120.789 2.542 266.655c0 145.866 118.247 264.113 264.113 264.113 145.866 0 264.113-118.247 264.113-264.113 0-145.866-118.247-264.113-264.113-264.113Zm104.01 67.283c2.877.134 5.167 1.168 6.615 2.285 1.93 1.49 5.06 2.73 5.81 4.47.15.74-.6 2.83 3.58 3.58s6.971 1.811 6.971 1.811 6.8 1.87 1.35 3.36c-5.51.89-8.92-.31-10.56.74-1.64 1.05-3.13 4.38-4.17 4.83-1.04.45-3.13.15-5.21 1.79-2.08 1.64-4.171 2.979-5.511 6.259-1.34 3.28-2.379 6.851-1.489 11.621.89 4.77 3.72 13.71 6.26 18.77 2.53 5.07 8.79 17.73 10.58 20.71 1.79 2.98 6.85 12.81 7.3 23.39.45 10.58.45 19.219 0 23.839-.45 4.62-4.71 22.452-15.44 37.352-3.54 6.14-10.091 13.858-10.721 15.218-.63 1.36-1.31 2.12-.7 3.32 2.01 3.93 5.591 9.62 6.391 12.7 1 2.89 3.04 7.01 3.14 12.48h-.04c.1 5.47-5.19 22.37-16.81 35.33-11.62 12.96-24.341 9.06-25.391 8.76-1.05-.3-1.34-1.939-3.43-1.639-2.09.3-4.17 2.68-8.19 3.13-4.02.45-7.6.74-11.32-.6-1.94.3-2.229.31-3.129.97-.9.66-7.75 4.689-9.39 4.989 0 3.13-.6 19.37 0 24.29.6 4.92 1.64 11.171 3.28 15.641 1.64 4.47 1.19 4.619 3.28 4.319 2.09-.3 5.81.15 6.85 1.79 1.04 1.64.599 2.83 2.379 3.58s26.37 7.45 43.21 8.34c11.62.3 18.621 1.19 22.651 8.49.6 3.43 3.28 5.66 4.92 6.11 1.64.45 3.58.892 5.21 2.532 1.63 1.64.45 3.578 2.38 5.068 1.94 1.49 6.41 3.72 11.03 5.96 4.62 2.23 6.559 5.81 6.849 7 .29 1.19.74 4.77.74 4.77s-3.87 0-4.77-.89c-.9-.89-1.189-2.53-1.189-2.53s-2.681.45-3.281 1.49c-.6 1.04.3 1.191-3.43.301-3.73-.89-5.07-1.64-7.9-3.72s-7.15-7.451-10.88-7.301c1.79 3.58 2.53 3.581 4.62 4.771 2.09 1.19 3.58 2.53 3.58 2.53l-3.129 1.94s-1.94.89-6.41-.3c-4.47-1.19-5.07-1.94-5.51-4.62-.44-2.68 0-4.02-1.191-6.26-1.19-2.24-1.79-6.85-9.68-9.24-7.9-2.38-28.91-9.241-43.51-10.731-14.6-1.49-9.83-1.19-12.22-.6-2.39.59-6.849 1.94-11.469-.6-4.62-2.53-7.3-5.958-7.6-8.788-.3-2.83-.74-4.772 0-8.342.74-3.57 1.49-8.64.74-11.62-.75-2.98-6.11-24.289-8.64-27.859-5.66.45-22.05-.3-22.05-.3s-1.04-.301-7.6 2.529-15.85 5.98-20.32 8.66c-4.47 2.68-6.26 4.22-7 5.71.05 1.3-.53 3.35-1.21 3.94-.68.59-5.42 1.051-5.42 1.051s-2.54 2.54-3.58 4.33c-2.98 4.17-23.99 54.38-28.91 65.11-5.89 14.15-4.25 11.92-16.39 11.92s-3.5.13-3.5.13-5.361 2.55-7.301 2.55c-1.94 0-8.189-1.04-11.469 0-3.28 1.04-7.15 4.02-10.13 4.47-2.98.45-2.15-.581-3.64-1.631-1.74-.09-6.2-.049-6.2-.049s.759-2.83 3.439-4.73c3.58-2.53 11.03-7 15.05-9.24 4.02-2.23 7.601-3.43 10.131-4.02 2.53-.6 8.05-2.68 13.26-4.32 5.21-1.64 8.49-3.58 12.96-13.71 4.47-10.13 21.899-48.421 22.349-50.511.28-1.29 1.622-2.809 2.092-6.559.29-2.32-1.082-5.67.738-9.83 4.77-10.88 9.39-8.79 11.47-8.79s7.451.15 11.471-.6c4.02-.74 6.699-1.19 10.729-3.43 4.02-2.23 4.922-3.871 4.922-3.871l-8.641-1.488s-8.05.298-10.88-1.942c-2.83-2.24-6.7-6.85-6.7-6.85l-3.28-3.128.45 3.718s-2.23-3.868-2.83-4.468c-.6-.6-3.72-2.832-4.92-4.922-1.2-2.09-3.28-10.88-3.28-10.88l-2.681 5.661-.738-8.19-2.23 3.13-1.192-7-2.09 3.58-1.488-6.26-2.532 1.94-1.039-4.47-2.531-1.49s-6.108-4.02-7.148-4.17c-1.04-.15-1.942 1.49-1.942 1.49l.6 5.21-5.299-2.721-3.041-5.17s1.02 1.741-4.18 3.271c-8.93 0-9.51-.99-10.83-1.71-.57-2.85-2.42-4.391-2.42-4.391h-8.94l-.15-2.979-3.43 1.34.302-4.47h-6.112l.451-4.92h-3.87s1.34-4.02 11.17-9.98c9.83-5.96 10.88-7.15 20.11-7.45 9.24-.3 14.3 1.19 20.11 3.58 5.81 2.38 16.84 5.96 19.07 7.6 4.92-4.32 12.37-8.641 14.9-8.641.45-1.04 2.69-5.358 7.81-7.648 15.34-6.87 46.99-14.782 57.16-15.452 13.56-.89 3.43-.599 16.99.891 13.56 1.49 23.54 3.28 30.25 5.51 5.46 1.82 10.88 4.17 14.9.89 1.88-.79 5.13-.58 7.75-2.23 13.34-10.9 15.089-14.06 18.959-18.23 3.87-4.17 8.75-10.83 9.8-19.62 1.05-8.79 1.942-25.33-4.618-38.29-6.56-12.96-12.521-19.67-15.051-30.1-2.53-10.43-3.58-26.52-2.68-31.74.9-5.22 2.23-9.68 2.98-13.26s4.32-6.41 8.64-7.15c1.08-.186 2.105-.24 3.064-.196z" fill="currentColor"/></symbol>
diff --git a/BTCPayServer/wwwroot/js/wallet/wallet-transactions-columns.js b/BTCPayServer/wwwroot/js/wallet/wallet-transactions-columns.js
new file mode 100644
index 0000000..abe900e
--- /dev/null
+++ b/BTCPayServer/wwwroot/js/wallet/wallet-transactions-columns.js
@@ -0,0 +1,294 @@
+// Wallet transactions table column customization.
+// Lets users show/hide and reorder the table's content columns, persisting the
+// choice in localStorage. Column-agnostic: the available columns are derived
+// from the header cells tagged with `data-col` / `data-col-label`, so the
+// control adapts automatically as columns are added or removed from the view.
+(function () {
+ const STORAGE_KEY_BASE = 'btcpay-wallet-tx-columns';
+ const TABLE_ID = 'WalletTransactions';
+ const BODY_ID = 'WalletTransactionsList';
+ const LIST_ID = 'ColumnsList';
+ const RESET_ID = 'ColumnsReset';
+ const DRAGGING_CLASS = 'wallet-columns__item--dragging';
+ const SVG_NS = 'http://www.w3.org/2000/svg';
+
+ let state = { registry: [], order: [], hidden: [] };
+ let storeId = '';
+ let moveLabelFormat = 'Move {0}'; // localized template supplied by the view
+
+ const getTable = () => document.getElementById(TABLE_ID);
+ const getHeaderRow = () => {
+ const table = getTable();
+ return table ? table.querySelector('thead.mass-action-head tr') : null;
+ };
+
+ // Available columns, in header (DOM) order, deduped by key (e.g. multiple rate columns).
+ // Cached on first read so it reflects the original layout, since apply() reorders the header DOM
+ // and a later Reset must restore that original order rather than the reordered one.
+ let registryCache = null;
+ const getRegistry = () => {
+ if (registryCache) return registryCache;
+ const header = getHeaderRow();
+ if (!header) return [];
+ const seen = new Set();
+ const cols = [];
+ header.querySelectorAll(':scope > [data-col]').forEach(th => {
+ const key = th.dataset.col;
+ if (!key || seen.has(key)) return;
+ seen.add(key);
+ cols.push({ key, label: th.dataset.colLabel || key });
+ });
+ registryCache = cols;
+ return cols;
+ };
+
+ // Scope preferences to the store and current column set ("schema").
+ const storageKey = () => `${STORAGE_KEY_BASE}:${storeId}`;
+
+ const loadSaved = () => {
+ try {
+ const parsed = JSON.parse(window.localStorage.getItem(storageKey()));
+ if (!parsed || !Array.isArray(parsed.order)) return null;
+ return { order: parsed.order, hidden: Array.isArray(parsed.hidden) ? parsed.hidden : [] };
+ } catch (e) {
+ return null;
+ }
+ };
+
+ const persist = () => {
+ try {
+ window.localStorage.setItem(storageKey(), JSON.stringify({ order: state.order, hidden: state.hidden }));
+ } catch (e) { /* storage unavailable — degrade gracefully */ }
+ };
+
+ // Reconcile any saved preference with the columns actually present.
+ const resolveState = () => {
+ const registry = getRegistry();
+ const keys = registry.map(c => c.key);
+ const saved = loadSaved();
+ if (!saved) return { registry, order: [...keys], hidden: [] };
+ // Keep only known keys, de-duplicated, then append any columns missing from the saved order.
+ const order = [...new Set(saved.order.filter(k => keys.includes(k)))];
+ keys.forEach(k => { if (!order.includes(k)) order.push(k); });
+ let hidden = [...new Set(saved.hidden.filter(k => keys.includes(k)))];
+ // Never let a stale preference hide every available column.
+ if (keys.length && hidden.length >= keys.length) hidden = [];
+ return { registry, order, hidden };
+ };
+
+ const isHidden = key => state.hidden.includes(key);
+
+ const bodyRows = table => {
+ const tbody = table.querySelector(`tbody#${BODY_ID}`);
+ return tbody ? [...tbody.children].filter(n => n.tagName === 'TR') : [];
+ };
+
+ // Apply visibility + order to the header row and every body row.
+ const apply = () => {
+ const table = getTable();
+ if (!table) return;
+
+ state.registry.forEach(col => {
+ const hide = isHidden(col.key);
+ table.querySelectorAll(`[data-col="${col.key}"]`).forEach(cell => cell.classList.toggle('d-none', hide));
+ });
+
+ [getHeaderRow(), ...bodyRows(table)].forEach(row => {
+ if (!row) return;
+ const cells = [...row.querySelectorAll(':scope > [data-col]')];
+ if (cells.length < 2) return;
+ // The cell after the customizable group (e.g. the actions cell) stays fixed;
+ // re-inserting each cell before it rebuilds the group in the chosen order.
+ const anchor = cells[cells.length - 1].nextElementSibling;
+ state.order.forEach(key => {
+ cells.filter(c => c.dataset.col === key).forEach(c => row.insertBefore(c, anchor));
+ });
+ });
+ };
+
+ const spriteBase = () => {
+ const use = document.querySelector('svg.icon use');
+ const href = use && (use.getAttribute('href') || use.getAttribute('xlink:href'));
+ return href ? href.split('#')[0] : '';
+ };
+
+ const makeHandle = label => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'wallet-columns__handle';
+ btn.setAttribute('aria-label', moveLabelFormat.replace('{0}', () => label));
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('role', 'img');
+ svg.setAttribute('class', 'icon icon-actions-drag');
+ const use = document.createElementNS(SVG_NS, 'use');
+ use.setAttribute('href', `${spriteBase()}#actions-drag`);
+ svg.appendChild(use);
+ btn.appendChild(svg);
+ return btn;
+ };
+
+ const buildList = () => {
+ const list = document.getElementById(LIST_ID);
+ if (!list) return;
+ const labels = Object.fromEntries(state.registry.map(c => [c.key, c.label]));
+ list.textContent = '';
+ state.order.forEach(key => {
+ if (!(key in labels)) return;
+ const li = document.createElement('li');
+ li.className = 'wallet-columns__item';
+ li.dataset.col = key;
+
+ const check = document.createElement('div');
+ check.className = 'form-check mb-0';
+ const input = document.createElement('input');
+ input.className = 'form-check-input';
+ input.type = 'checkbox';
+ input.id = `col-toggle-${key}`;
+ input.checked = !isHidden(key);
+ const label = document.createElement('label');
+ label.className = 'form-check-label';
+ label.htmlFor = input.id;
+ label.textContent = labels[key];
+ check.append(input, label);
+
+ li.append(makeHandle(labels[key]), check);
+ list.appendChild(li);
+ });
+ };
+
+ const onToggle = event => {
+ const input = event.target;
+ const key = input.closest('li[data-col]')?.dataset.col;
+ if (!key) return;
+ if (input.checked) {
+ state.hidden = state.hidden.filter(k => k !== key);
+ } else if (state.order.filter(k => !isHidden(k)).length <= 1) {
+ // Keep at least one column visible.
+ input.checked = true;
+ return;
+ } else if (!state.hidden.includes(key)) {
+ state.hidden.push(key);
+ }
+ persist();
+ apply();
+ };
+
+ // Read the current DOM order of the list back into state, then persist + apply.
+ const commitOrder = list => {
+ state.order = [...list.querySelectorAll('li[data-col]')].map(el => el.dataset.col);
+ persist();
+ apply();
+ };
+
+ // Reorder a row by one step; keyboard-accessible alternative to dragging.
+ const moveItem = (li, dir) => {
+ const list = li.parentElement;
+ if (dir < 0 && li.previousElementSibling) list.insertBefore(li, li.previousElementSibling);
+ else if (dir > 0 && li.nextElementSibling) list.insertBefore(li.nextElementSibling, li);
+ else return;
+ commitOrder(list);
+ li.querySelector('.wallet-columns__handle').focus();
+ };
+
+ const wire = list => {
+ delegate('change', 'input[type="checkbox"]', onToggle, list);
+
+ delegate('keydown', '.wallet-columns__handle', event => {
+ const li = event.target.closest('li[data-col]');
+ if (!li) return;
+ if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ moveItem(li, -1);
+ } else if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ moveItem(li, 1);
+ }
+ }, list);
+
+ // Pointer-based dragging works with mouse, touch and pen (HTML5 drag events
+ // never fire on touch). Unlike native drag there's no browser "ghost", so the
+ // dragged row follows the pointer via a transform — making it obvious what is
+ // being moved — while the other rows reflow underneath it.
+ delegate('pointerdown', '.wallet-columns__handle', event => {
+ if (event.button != null && event.button !== 0) return; // primary button / touch / pen only
+ const li = event.target.closest('li[data-col]');
+ if (!li) return;
+ event.preventDefault();
+
+ // Safety net: clear any leftover drag state (e.g. from an interrupted drag).
+ list.querySelectorAll(`.${DRAGGING_CLASS}`).forEach(el => {
+ el.classList.remove(DRAGGING_CLASS);
+ el.style.transform = '';
+ });
+
+ // Track the dragged row in a local (not shared) so a second, concurrent
+ // pointer (multi-touch, palm, fast re-tap) can't hijack an in-flight drag.
+ const item = li;
+ item.classList.add(DRAGGING_CLASS);
+ const pointerId = event.pointerId;
+ // startY is the pointer position at which the current transform baseline is 0.
+ let startY = event.clientY;
+
+ const onMove = e => {
+ if (e.pointerId !== pointerId) return;
+ item.style.transform = `translateY(${e.clientY - startY}px)`;
+
+ // Once the dragged row's centre passes a neighbour's centre, swap them,
+ // then nudge startY so the row stays visually pinned under the pointer.
+ const rect = item.getBoundingClientRect();
+ const mid = rect.top + rect.height / 2;
+ const next = item.nextElementSibling;
+ const prev = item.previousElementSibling;
+ let moved = false;
+ if (next) {
+ const nb = next.getBoundingClientRect();
+ if (mid > nb.top + nb.height / 2) { list.insertBefore(item, next.nextElementSibling); moved = true; }
+ }
+ if (!moved && prev) {
+ const pb = prev.getBoundingClientRect();
+ if (mid < pb.top + pb.height / 2) { list.insertBefore(item, prev); moved = true; }
+ }
+ if (moved) {
+ startY += item.getBoundingClientRect().top - rect.top;
+ item.style.transform = `translateY(${e.clientY - startY}px)`;
+ }
+ };
+ // Listen on document so the drag always ends cleanly, even if pointer capture
+ // is lost when the dragged row is re-inserted in the DOM (which would otherwise
+ // leave the row's highlight stuck).
+ const onUp = e => {
+ if (e.pointerId !== pointerId) return;
+ document.removeEventListener('pointermove', onMove);
+ document.removeEventListener('pointerup', onUp);
+ document.removeEventListener('pointercancel', onUp);
+ item.style.transform = '';
+ item.classList.remove(DRAGGING_CLASS);
+ commitOrder(list);
+ };
+ document.addEventListener('pointermove', onMove);
+ document.addEventListener('pointerup', onUp);
+ document.addEventListener('pointercancel', onUp);
+ }, list);
+
+ delegate('click', `#${RESET_ID}`, () => {
+ try {
+ window.localStorage.removeItem(storageKey());
+ } catch (e) { /* storage unavailable — degrade gracefully */ }
+ state = resolveState();
+ buildList();
+ apply();
+ });
+ };
+
+ const listEl = document.getElementById(LIST_ID);
+ if (getTable() && getHeaderRow() && listEl) {
+ storeId = listEl.dataset.storeId;
+ moveLabelFormat = listEl.dataset.moveLabel || moveLabelFormat;
+ state = resolveState();
+ buildList();
+ apply();
+ wire(listEl);
+ // Expose so the infinite-scroll loader can re-apply after appending rows.
+ window.WalletTxColumns = { apply };
+ }
+})();
diff --git a/BTCPayServer/wwwroot/main/site.css b/BTCPayServer/wwwroot/main/site.css
index e74d92f..913c3f7 100644
--- a/BTCPayServer/wwwroot/main/site.css
+++ b/BTCPayServer/wwwroot/main/site.css
@@ -1194,6 +1194,100 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
text-align: right;
}
+/* Column customization */
+#ColumnsDropdownToggle {
+ /* Square icon button whose size equals the standard button height, so it lines up with the sibling filter buttons. */
+ --wallet-columns-btn-size: calc(var(--btcpay-btn-font-size) * var(--btcpay-btn-line-height) + 2 * var(--btcpay-btn-padding-y) + 2 * var(--btcpay-btn-border-width));
+
+ flex: 0 0 auto;
+ min-width: 0;
+ width: var(--wallet-columns-btn-size);
+ height: var(--wallet-columns-btn-size);
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+#ColumnsDropdownToggle .icon {
+ /* Neutralize the icon+text balancing margin so the lone icon is truly centered. */
+ margin-left: 0;
+}
+.wallet-columns__menu {
+ min-width: 15rem;
+ padding: var(--btcpay-space-s) 0;
+}
+.wallet-columns__hint {
+ padding: 0 var(--btcpay-space-m) var(--btcpay-space-s);
+ font-size: var(--btcpay-font-size-s);
+}
+.wallet-columns__list {
+ max-height: 20rem;
+ overflow-y: auto;
+}
+.wallet-columns__item {
+ display: flex;
+ align-items: center;
+ gap: var(--btcpay-space-s);
+ padding: var(--btcpay-space-xs) var(--btcpay-space-m);
+ cursor: grab;
+}
+/* Only on devices with a real hovering pointer — otherwise the highlight sticks after a tap on touch. */
+@media (hover: hover) and (pointer: fine) {
+ .wallet-columns__item:hover {
+ background-color: var(--btcpay-neutral-200);
+ }
+}
+.wallet-columns__item--dragging {
+ /* No browser drag-ghost with Pointer Events, so the row itself follows the
+ pointer (via transform) and is lifted above its siblings to make the move obvious. */
+ background-color: var(--btcpay-neutral-200);
+ border-radius: var(--btcpay-border-radius);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, .18);
+ position: relative;
+ z-index: 2;
+ cursor: grabbing;
+ user-select: none;
+ will-change: transform;
+}
+.wallet-columns__handle {
+ display: inline-flex;
+ padding: 0;
+ background: none;
+ border: 0;
+ color: var(--btcpay-body-text-muted);
+ cursor: grab;
+ /* Let pointer-based dragging start on touch without the browser scrolling instead. */
+ touch-action: none;
+}
+.wallet-columns__item .form-check {
+ flex: 1 1 auto;
+ margin-bottom: 0;
+}
+.wallet-columns__reset {
+ padding: var(--btcpay-space-xs) var(--btcpay-space-m) 0;
+ text-decoration: none;
+}
+/* Roomier rows and ~44px tap targets on touch devices, without changing the
+ compact desktop layout. */
+@media (pointer: coarse) {
+ .wallet-columns__item {
+ min-height: 3rem;
+ padding-top: var(--btcpay-space-s);
+ padding-bottom: var(--btcpay-space-s);
+ }
+ .wallet-columns__handle {
+ /* Fill the row height so the grab area is tall without overhanging into neighbours. */
+ align-self: stretch;
+ min-width: 2.75rem;
+ align-items: center;
+ justify-content: center;
+ }
+ .wallet-columns__item .form-check-label {
+ display: inline-block;
+ padding: var(--btcpay-space-xs) 0;
+ }
+}
+
/* Mass Actions */
.mass-action-head,
.mass-action-actions {
Why this scored 23/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.