From 17ad4899730bb80db07fb7cf6e57a8fba7552d6d Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 22:26:09 +0100 Subject: [PATCH] chore: add BranchEntryTable --- src/components/orderbook/OrderbookTable.tsx | 4 +- .../wallet/AccountDetailsTabContent.tsx | 68 +++- src/components/wallet/BranchEntryTable.tsx | 350 ++++++++++++++++++ src/components/wallet/JarUtxosTable.tsx | 4 +- .../wallet/WalletJarsDetailsContent.tsx | 2 +- src/context/JamWalletInfoContext.ts | 4 +- src/types/global.d.ts | 7 + 7 files changed, 421 insertions(+), 18 deletions(-) create mode 100644 src/components/wallet/BranchEntryTable.tsx diff --git a/src/components/orderbook/OrderbookTable.tsx b/src/components/orderbook/OrderbookTable.tsx index 13b7cf8f..8463c7ec 100644 --- a/src/components/orderbook/OrderbookTable.tsx +++ b/src/components/orderbook/OrderbookTable.tsx @@ -171,7 +171,9 @@ export const OrderbookTable = ({ header: () =>
{t('orderbook.table.heading_fee')}
, // Custom sorting: absolute before relative, then by fee value sortingFn: (a, b) => { - if (a.original.type.isAbsolute !== b.original.type.isAbsolute) return a.original.type.isAbsolute ? -1 : 1 + if (a.original.type.isAbsolute !== b.original.type.isAbsolute) { + return a.original.type.isAbsolute ? -1 : 1 + } return a.original.fee.value - b.original.fee.value }, cell: (info) => { diff --git a/src/components/wallet/AccountDetailsTabContent.tsx b/src/components/wallet/AccountDetailsTabContent.tsx index 0aa6a9a3..d436ffa5 100644 --- a/src/components/wallet/AccountDetailsTabContent.tsx +++ b/src/components/wallet/AccountDetailsTabContent.tsx @@ -1,9 +1,14 @@ +import { useMemo, useState } from 'react' +import type { RowModel } from '@tanstack/react-table' import type { TFunction } from 'i18next' import { useTranslation } from 'react-i18next' -import { type AccountMeta } from '@/context/JamWalletInfoContext' +import type { AccountBranch, AccountMeta } from '@/context/JamWalletInfoContext' +import { btcToSats, isValidNumber } from '@/lib/utils' +import type { HdPath } from '@/types/global' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' +import { BranchEntryTable, type BranchEntryApiObject, type BranchEntryTableRow } from './BranchEntryTable' -const HIDE_EMPTY_BRANCHES = false +const HIDE_EMPTY_BRANCHES = true const toTypeHeading = (type: string, t: TFunction) => { if (type === 'external addresses') { @@ -14,6 +19,54 @@ const toTypeHeading = (type: string, t: TFunction) => { return type } +const toLastHdPathIndex = (hdPath: HdPath): number | null => { + const indexOfLastSeparator = hdPath.lastIndexOf('/') + if (indexOfLastSeparator === -1 || indexOfLastSeparator === hdPath.length - 1) { + return null + } + + const stringValue = hdPath.substring(indexOfLastSeparator + 1, hdPath.length) + const indexOfFirstColon = stringValue.indexOf(':') // value can be `/76:1777593600` + + const sanitizedStringValue = indexOfFirstColon === -1 ? stringValue : stringValue.substring(0, indexOfFirstColon) + const numberValue = parseInt(sanitizedStringValue, 10) + return !isValidNumber(numberValue) ? null : numberValue +} + +const branchToTableEntry = (value: BranchEntryApiObject): BranchEntryTableRow => { + return { + ...value, + derivationIndex: toLastHdPathIndex(value.hd_path as HdPath) ?? -1, + derivationPath: (value.hd_path !== undefined ? value.hd_path : 'm/-1') as HdPath, + address: value.address || '', + balance: btcToSats(value.amount || '0'), + } +} + +interface BranchAccordionContentProps { + value: AccountBranch +} + +const BranchAccordionContent = ({ value }: BranchAccordionContentProps) => { + const [_tableRowModel, setTableRowModel] = useState>() + + const tableEntries = useMemo(() => { + return (value.__raw.entries || []).map((it) => branchToTableEntry(it)) + }, [value]) + + return ( + { + setTableRowModel(table.getFilteredRowModel()) + }} + /> + ) +} + interface AccountDetailsTabContentProps { value: AccountMeta } @@ -44,18 +97,11 @@ export const AccountDetailsTabContent = ({ value }: AccountDetailsTabContentProp {typeTitle} - ({branch.derivation}) + {branch.derivation} -
-
-
- branch: -
{JSON.stringify(branch, null, 2)}
-
-
-
+
) diff --git a/src/components/wallet/BranchEntryTable.tsx b/src/components/wallet/BranchEntryTable.tsx new file mode 100644 index 00000000..7728b739 --- /dev/null +++ b/src/components/wallet/BranchEntryTable.tsx @@ -0,0 +1,350 @@ +import { useState, useMemo, useEffect } from 'react' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + getPaginationRowModel, + type SortingState, + type ColumnDef, + useReactTable, + type RowPinningState, + type RowSelectionState, + type VisibilityState, + type Column, + type FilterFn, + type FilterFnOption, + type Table as TableType, +} from '@tanstack/react-table' +import { + ArrowUpDownIcon, + SortDescIcon, + SortAscIcon, + ArrowUp01Icon, + ArrowDown10Icon, + ArrowDownZAIcon, + ArrowUpAZIcon, +} from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { TablePagination } from '@/components/ui/jam/TablePagination' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import type { AccountBranch } from '@/context/JamWalletInfoContext' +import { cn } from '@/lib/utils' +import type { AmountSats, BitcoinAddress, HdPath } from '@/types/global' +import { Balance } from '../ui/jam/Balance' + +const ITEMS_PER_PAGE = 25 + +export type BranchEntryApiObject = NonNullable[number] + +export type BranchEntryTableRow = BranchEntryApiObject & { + derivationIndex: number + derivationPath: HdPath + address: BitcoinAddress + balance: AmountSats +} + +type SortKey = keyof BranchEntryTableRow + +const columnHelper = createColumnHelper() + +type BranchEntryTableColumnMeta = + | { + align?: string + numeric?: boolean + alphabetic?: boolean + } + | undefined + +interface SortIconProps { + className?: string + sortKey: SortKey + column: Column +} +const SortIcon = ({ column, className }: SortIconProps) => { + const dir = column.getIsSorted() + if (!dir) return + const meta = column.columnDef.meta as BranchEntryTableColumnMeta + if (meta?.numeric === true) { + return dir === 'desc' ? : + } + if (meta?.alphabetic === true) { + return dir === 'desc' ? : + } + return dir === 'desc' ? : +} + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta({ itemRank }) + return itemRank.passed +} + +interface BranchEntryTableProps { + globalFilter?: string + tableEntries: BranchEntryTableRow[] + selectedEntries: BranchEntryTableRow[] + pinnedEntries: BranchEntryTableRow[] + onChange?: (table: TableType) => void +} + +export const BranchEntryTable = ({ + globalFilter, + tableEntries, + selectedEntries: highlightedEntries, + pinnedEntries, + onChange, +}: BranchEntryTableProps) => { + const { t } = useTranslation() + + const [currentPage, setCurrentPage] = useState(1) + const [itemsPerPage, setItemsPerPage] = useState(ITEMS_PER_PAGE) + const [sorting, setSorting] = useState([]) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const columns = useMemo[]>( + () => [ + columnHelper.accessor('derivationIndex', { + header: () =>
, + sortingFn: (a, b) => { + return a.original.derivationIndex - b.original.derivationIndex + }, + cell: (info) => ( + + …/ + {info.getValue()} + + ), + meta: { + align: 'right', + numeric: true, + }, + }), + columnHelper.accessor('address', { + header: () =>
{t('jar_details.utxo_list.column_title_address')}
, + sortingFn: (a, b) => { + const val = a.original.address.localeCompare(b.original.address) + if (val !== 0) return val + // tie-break using derivationIndex + return a.original.derivationIndex - b.original.derivationIndex + }, + cell: (info) => {info.getValue()}, + meta: { + alphabetic: true, + }, + }), + columnHelper.accessor('balance', { + header: () =>
{t('jar_details.utxo_list.column_title_balance')}
, + sortingFn: (a, b) => { + const val = a.original.balance - b.original.balance + if (val !== 0) return val + // tie-break using derivationIndex + return a.original.derivationIndex - b.original.derivationIndex + }, + cell: (info) => , + meta: { + align: 'right', + numeric: true, + }, + }), + ], + [t], + ) + const [columnVisibility, setColumnVisibility] = useState({ + minerFeeContribution: false, + }) + const [rowSelection, setRowSelection] = useState({}) + const [rowPinning, setRowPinning] = useState({ + top: [], + bottom: [], + }) + + const table = useReactTable({ + data: tableEntries, + columns, + filterFns: { + fuzzy: fuzzyFilter, //define as a filter function that can be used in column definitions + }, + state: { + globalFilter, + sorting, + pagination: { + pageIndex: Math.max(0, currentPage - 1), + pageSize: itemsPerPage === -1 ? tableEntries.length || 1 : itemsPerPage, + }, + rowPinning, + rowSelection, + columnVisibility, + }, + globalFilterFn: 'fuzzy' as FilterFnOption, + keepPinnedRows: true, + enableRowSelection: true, + onSortingChange: setSorting, + onRowPinningChange: setRowPinning, + onRowSelectionChange: setRowSelection, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + useEffect(() => { + table.resetRowPinning(true) + table.getRowModel().rows.forEach((row) => { + row.pin(pinnedEntries.includes(row.original) ? 'top' : false) + }) + }, [table, pinnedEntries]) + + useEffect(() => { + table.resetRowSelection(true) + table.getRowModel().rows.forEach((row) => { + row.toggleSelected(highlightedEntries.includes(row.original)) + }) + }, [table, highlightedEntries]) + + const totalPages = useMemo(() => { + if (itemsPerPage === -1) { + return 1 + } + return Math.max(1, Math.ceil(tableEntries.length / itemsPerPage)) + }, [itemsPerPage, tableEntries.length]) + + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(totalPages) + table.setPageIndex(Math.max(0, totalPages - 1)) + } + }, [totalPages, currentPage, table]) + + const handleSort = (key: SortKey) => { + const col = table.getColumn(key) + if (col) { + col.toggleSorting() + } + } + + const tableTopRows = () => { + try { + // pinned offers might not be included in the table data, + // and the internal model of the table does not match anymore + return table.getTopRows() + } catch (e) { + console.debug('Error while rendering top table rows', e) + return [] + } + } + + useEffect(() => { + if (onChange) { + onChange(table) + } + }, [table, onChange]) + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort() + const key = header.column.id as SortKey + const alignCenter = (header.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'center' + const alignRight = (header.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'right' + return ( + handleSort(key) : undefined} + > +
0 && !header.column.getIsSorted(), + })} + > + {flexRender(header.column.columnDef.header, header.getContext())} + {canSort ? : undefined} +
+
+ ) + })} +
+ ))} +
+ + {tableTopRows().map((row) => ( + + {row.getVisibleCells().map((cell) => { + const alignCenter = (cell.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'center' + const alignRight = (cell.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'right' + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + ))} + {table.getCenterRows().map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + const alignCenter = (cell.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'center' + const alignRight = (cell.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'right' + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + ) + })} + +
+
+ + { + setCurrentPage(page) + table.setPageIndex(Math.max(0, page - 1)) + }} + onItemsPerPageChange={(newItemsPerPage) => { + setItemsPerPage(newItemsPerPage) + const size = newItemsPerPage === -1 ? table.getPrePaginationRowModel().rows.length || 1 : newItemsPerPage + table.setPageSize(size) + setCurrentPage(1) + table.setPageIndex(0) + }} + /> +
+ ) +} diff --git a/src/components/wallet/JarUtxosTable.tsx b/src/components/wallet/JarUtxosTable.tsx index d1e298b8..27b7478d 100644 --- a/src/components/wallet/JarUtxosTable.tsx +++ b/src/components/wallet/JarUtxosTable.tsx @@ -105,14 +105,14 @@ export const JarUtxosTable = ({ columnHelper.accessor('value', { header: () =>
{t('jar_details.utxo_list.column_title_balance')}
, sortingFn: (a, b) => { - const val = a.original.address.localeCompare(b.original.address) + const val = a.original.value - b.original.value if (val !== 0) return val // tie-break using confirmations const aid = Number(a.original.confirmations) const bid = Number(b.original.confirmations) return aid - bid }, - cell: (info) => , + cell: (info) => , meta: { align: 'right', numeric: true, diff --git a/src/components/wallet/WalletJarsDetailsContent.tsx b/src/components/wallet/WalletJarsDetailsContent.tsx index 5c4c973f..f60ea779 100644 --- a/src/components/wallet/WalletJarsDetailsContent.tsx +++ b/src/components/wallet/WalletJarsDetailsContent.tsx @@ -69,7 +69,7 @@ export const UtxosContent = ({ enabled: _enabled, addressSummary, jar }: UtxosCo
- +
diff --git a/src/context/JamWalletInfoContext.ts b/src/context/JamWalletInfoContext.ts index 43de8766..3bb0ced3 100644 --- a/src/context/JamWalletInfoContext.ts +++ b/src/context/JamWalletInfoContext.ts @@ -4,7 +4,7 @@ import type { AddressInfo } from 'bitcoin-address-validation' import type { UseQueryDisplayWalletResult } from '@/hooks/useQueryDisplayWallet' import type { FidelityBondUtxo, UseQueryUtxosResult, Utxo } from '@/hooks/useQueryUtxos' import type { BalanceSummary } from '@/lib/balanceSummary' -import type { BitcoinAddress, JarIndex } from '@/types/global' +import type { BitcoinAddress, HdPath, JarIndex } from '@/types/global' // Comments for tailwind importer (ADAPT THE COMMENT IF YOU CHANGE THE VALUE) // "text-[#e2b86a]", "group-hover/jar:text-[#e2b86a]" @@ -56,8 +56,6 @@ export type AccountBranchType = 'external addresses' | 'internal addresses' type AccountAbiObj = NonNullable['accounts']>[number] -type HdPath = `m/${string}` - export type AccountBranch = { type: AccountBranchType | string derivation: HdPath diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 04aa3c6e..e379d849 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -5,6 +5,13 @@ export type JarIndex = number export type SeedPhrase = string[] +export type HdPath = + | `m/${number}'/${number}'/${number}'` // used internally + | `m/${number}'/${number}'/${number}'/${number}` // used by jm + | `m/${number}'/${number}'/${number}'/${number}/${number}` // used by jm + | `m/${number}'/${number}'/${number}'/${number}/${number}:${number}` // used by jm for Fidelity Bonds + | `m/${string}` // catch all + export type Milliseconds = number export type Seconds = number export type Days = number