mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-19 13:18:30 +02:00
chore: add BranchEntryTable
This commit is contained in:
parent
16dc58220e
commit
17ad489973
7 changed files with 421 additions and 18 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
350
src/components/wallet/BranchEntryTable.tsx
Normal file
350
src/components/wallet/BranchEntryTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
src/types/global.d.ts
vendored
7
src/types/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue