chore: add BranchEntryTable

This commit is contained in:
theborakompanioni 2026-01-18 22:26:09 +01:00
parent 16dc58220e
commit 17ad489973
No known key found for this signature in database
GPG key ID: E8070AF0053AAC0D
7 changed files with 421 additions and 18 deletions

View file

@ -171,7 +171,9 @@ export const OrderbookTable = ({
header: () => <div className="flex items-center justify-end">{t('orderbook.table.heading_fee')}</div>,
// 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) => {

View file

@ -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<RowModel<BranchEntryTableRow>>()
const tableEntries = useMemo(() => {
return (value.__raw.entries || []).map((it) => branchToTableEntry(it))
}, [value])
return (
<BranchEntryTable
tableEntries={tableEntries}
selectedEntries={[]}
pinnedEntries={[]}
globalFilter={''}
onChange={(table) => {
setTableRowModel(table.getFilteredRowModel())
}}
/>
)
}
interface AccountDetailsTabContentProps {
value: AccountMeta
}
@ -44,18 +97,11 @@ export const AccountDetailsTabContent = ({ value }: AccountDetailsTabContentProp
<span className="text-base font-medium group-hover/account-branch-accordion-trigger:underline">
{typeTitle}
</span>
<code className="text-muted-foreground text-xs">({branch.derivation})</code>
<code className="text-muted-foreground text-xs">{branch.derivation}</code>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3">
<div className="space-y-1">
<div className="overflow-scroll">
<code className="light:text-red-700 text-red-800">branch:</code>
<pre className="text-xs">{JSON.stringify(branch, null, 2)}</pre>
</div>
</div>
</div>
<BranchAccordionContent value={branch} />
</AccordionContent>
</AccordionItem>
)

View file

@ -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<AccountBranch['__raw']['entries']>[number]
export type BranchEntryTableRow = BranchEntryApiObject & {
derivationIndex: number
derivationPath: HdPath
address: BitcoinAddress
balance: AmountSats
}
type SortKey = keyof BranchEntryTableRow
const columnHelper = createColumnHelper<BranchEntryTableRow>()
type BranchEntryTableColumnMeta =
| {
align?: string
numeric?: boolean
alphabetic?: boolean
}
| undefined
interface SortIconProps {
className?: string
sortKey: SortKey
column: Column<BranchEntryTableRow, unknown>
}
const SortIcon = ({ column, className }: SortIconProps) => {
const dir = column.getIsSorted()
if (!dir) return <ArrowUpDownIcon className={className} />
const meta = column.columnDef.meta as BranchEntryTableColumnMeta
if (meta?.numeric === true) {
return dir === 'desc' ? <ArrowDown10Icon className={className} /> : <ArrowUp01Icon className={className} />
}
if (meta?.alphabetic === true) {
return dir === 'desc' ? <ArrowDownZAIcon className={className} /> : <ArrowUpAZIcon className={className} />
}
return dir === 'desc' ? <SortDescIcon className={className} /> : <SortAscIcon className={className} />
}
const fuzzyFilter: FilterFn<BranchEntryTableRow> = (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<BranchEntryTableRow>) => 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<SortingState>([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const columns = useMemo<ColumnDef<BranchEntryTableRow, any>[]>(
() => [
columnHelper.accessor('derivationIndex', {
header: () => <div className="flex items-center"></div>,
sortingFn: (a, b) => {
return a.original.derivationIndex - b.original.derivationIndex
},
cell: (info) => (
<code className="text-break">
<span className="text-muted-foreground">/</span>
{info.getValue()}
</code>
),
meta: {
align: 'right',
numeric: true,
},
}),
columnHelper.accessor('address', {
header: () => <div className="flex items-center">{t('jar_details.utxo_list.column_title_address')}</div>,
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) => <span className="font-mono text-sm">{info.getValue()}</span>,
meta: {
alphabetic: true,
},
}),
columnHelper.accessor('balance', {
header: () => <div className="flex items-center">{t('jar_details.utxo_list.column_title_balance')}</div>,
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) => <Balance colored={false} valueString={String(info.getValue())} />,
meta: {
align: 'right',
numeric: true,
},
}),
],
[t],
)
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
minerFeeContribution: false,
})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [rowPinning, setRowPinning] = useState<RowPinningState>({
top: [],
bottom: [],
})
const table = useReactTable<BranchEntryTableRow>({
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<BranchEntryTableRow>,
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 (
<div className="flex flex-1 flex-col gap-2 overflow-hidden rounded-lg border shadow-lg">
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{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 (
<TableHead
key={header.id}
className={cn({
'cursor-pointer select-none': canSort,
'text-center': alignCenter,
'text-right': alignRight,
})}
onClick={canSort ? () => handleSort(key) : undefined}
>
<div
className={cn('flex items-center gap-2', {
'cursor-pointer select-none': canSort,
'justify-center': alignCenter,
'justify-end': alignRight,
'font-bold': header.column.getIsSorted(),
'text-muted-foreground': table.getState().sorting.length > 0 && !header.column.getIsSorted(),
})}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort ? <SortIcon className="size-4" sortKey={key} column={header.column} /> : undefined}
</div>
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className=":bg-foreground [&>tr:nth-child(odd)]:bg-foreground/10 [&>tr]:hover:bg-foreground/20!">
{tableTopRows().map((row) => (
<TableRow key={row.id} className={row.getIsSelected() ? 'light:bg-yellow-500/30! bg-yellow-950!' : ''}>
{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 (
<TableCell
key={cell.id}
className={cn({
'text-center': alignCenter,
'text-right': alignRight,
})}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
)
})}
</TableRow>
))}
{table.getCenterRows().map((row) => {
return (
<TableRow key={row.id} className={row.getIsSelected() ? 'light:bg-yellow-500/30! bg-yellow-950!' : ''}>
{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 (
<TableCell
key={cell.id}
className={cn({
'text-center': alignCenter,
'text-right': alignRight,
})}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
itemsPerPage={itemsPerPage}
totalItems={tableEntries.length}
onPageChange={(page) => {
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)
}}
/>
</div>
)
}

View file

@ -105,14 +105,14 @@ export const JarUtxosTable = ({
columnHelper.accessor('value', {
header: () => <div className="flex items-center">{t('jar_details.utxo_list.column_title_balance')}</div>,
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) => <Balance valueString={String(info.getValue())} />,
cell: (info) => <Balance colored={false} valueString={String(info.getValue())} />,
meta: {
align: 'right',
numeric: true,

View file

@ -69,7 +69,7 @@ export const UtxosContent = ({ enabled: _enabled, addressSummary, jar }: UtxosCo
</div>
<div className="flex items-center gap-1">
<Trans i18nKey="jar_details.utxo_list.text_balance_sum_total">
<Balance valueString={String(jar.balanceSummary.calculatedTotalBalanceInSats)} />
<Balance colored={false} valueString={String(jar.balanceSummary.calculatedTotalBalanceInSats)} />
</Trans>
</div>
</div>

View file

@ -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<NonNullable<WalletDisplayResponse['walletinfo']>['accounts']>[number]
type HdPath = `m/${string}`
export type AccountBranch = {
type: AccountBranchType | string
derivation: HdPath

View file

@ -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