chore(orderbook): use filter function of library

This commit is contained in:
theborakompanioni 2026-01-10 15:01:18 +01:00
parent a45116b5d6
commit 16aa02b234
No known key found for this signature in database
GPG key ID: E8070AF0053AAC0D
8 changed files with 329 additions and 158 deletions

23
package-lock.json generated
View file

@ -26,6 +26,7 @@
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "1.2.8",
"@tailwindcss/vite": "4.1.18",
"@tanstack/match-sorter-utils": "8.19.4",
"@tanstack/react-query": "5.90.16",
"@tanstack/react-table": "8.21.3",
"class-variance-authority": "0.7.1",
@ -3460,6 +3461,22 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tanstack/match-sorter-utils": {
"version": "8.19.4",
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz",
"integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==",
"license": "MIT",
"dependencies": {
"remove-accents": "0.5.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz",
@ -7997,6 +8014,12 @@
"node": ">=8"
}
},
"node_modules/remove-accents": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",

View file

@ -61,6 +61,7 @@
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "1.2.8",
"@tailwindcss/vite": "4.1.18",
"@tanstack/match-sorter-utils": "8.19.4",
"@tanstack/react-query": "5.90.16",
"@tanstack/react-table": "8.21.3",
"class-variance-authority": "0.7.1",

View file

@ -1,6 +1,7 @@
import { useState, useMemo } from 'react'
import { DropdownMenu } from '@radix-ui/react-dropdown-menu'
import { useQuery } from '@tanstack/react-query'
import type { RowModel } from '@tanstack/react-table'
import type { i18n } from 'i18next'
import { ChevronDownIcon, RefreshCwIcon, PlusIcon, AlertCircleIcon, Loader2Icon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@ -171,34 +172,15 @@ export const OrderbookContent = ({ className }: OrderbookContentProps) => {
)
const pinnedToTopOffers = useMemo<OrderTableEntry[]>(() => (isPinMyOffers ? myOffers : []), [isPinMyOffers, myOffers])
const tableEntriesFiltered = useMemo(() => {
if (!tableEntries) return []
const searchVal = searchInputRaw.replace('.', '').toLowerCase()
let offers = tableEntries
if (searchVal !== '') {
offers = offers.filter((entry) => {
return (
entry.type.displayValue.toLowerCase().includes(searchVal) ||
entry.counterparty.toLowerCase().includes(searchVal) ||
entry.fee.displayValue.replace('.', '').toLowerCase().includes(searchVal) ||
entry.minimumSize.replace('.', '').toLowerCase().includes(searchVal) ||
entry.maximumSize.replace('.', '').toLowerCase().includes(searchVal) ||
entry.minerFeeContribution.replace('.', '').toLowerCase().includes(searchVal) ||
entry.bondValue.displayValue.replace('.', '').toLowerCase().includes(searchVal) ||
entry.orderId.toLowerCase().includes(searchVal)
)
})
}
return offers
}, [tableEntries, searchInputRaw])
const [tableRowModel, setTableRowModel] = useState<RowModel<OrderTableEntry>>()
const summary = useMemo(() => {
const uniqueCounterparties = new Set(tableEntriesFiltered.map((it) => it.counterparty))
const uniqueCounterparties = new Set((tableRowModel?.rows ?? []).map((it) => it.original.counterparty))
return {
count: tableEntriesFiltered.length,
count: tableRowModel?.rows.length || 0,
counterpartyCount: uniqueCounterparties.size,
}
}, [tableEntriesFiltered])
}, [tableRowModel])
const handleClearAndReload = async () => {
await refetchOrderbookRefresh().then(() => refetchOrderbookData())
@ -337,9 +319,13 @@ export const OrderbookContent = ({ className }: OrderbookContentProps) => {
</div>
) : (
<OrderbookTable
tableEntries={tableEntriesFiltered}
tableEntries={tableEntries}
selectedEntries={highlightedOffers}
pinnedEntries={pinnedToTopOffers}
globalFilter={searchInputRaw}
onChange={(table) => {
setTableRowModel(table.getFilteredRowModel())
}}
/>
)}
</div>

View file

@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react'
import { rankItem } from '@tanstack/match-sorter-utils'
import {
createColumnHelper,
flexRender,
@ -13,6 +14,9 @@ import {
type RowSelectionState,
type VisibilityState,
type Column,
type FilterFn,
type FilterFnOption,
type Table as TableType,
} from '@tanstack/react-table'
import {
ArrowUpDownIcon,
@ -26,7 +30,7 @@ import {
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Balance } from '@/components/ui/jam/Balance'
import { Pagination } from '@/components/ui/pagination'
import { TablePagination } from '@/components/ui/jam/TablePagination'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn, BTC } from '@/lib/utils'
@ -92,16 +96,26 @@ const SortIcon = ({ sortKey, column, className }: SortIconProps) => {
return dir === 'desc' ? <SortDescIcon className={className} /> : <SortAscIcon className={className} />
}
const fuzzyFilter: FilterFn<OrderTableEntry> = (row, columnId, value, addMeta) => {
const itemRank = rankItem(row.getValue(columnId), value)
addMeta({ itemRank })
return itemRank.passed
}
interface OrderbookTableProps {
globalFilter?: string
tableEntries: OrderTableEntry[]
selectedEntries: OrderTableEntry[]
pinnedEntries: OrderTableEntry[]
onChange?: (table: TableType<OrderTableEntry>) => void
}
export const OrderbookTable = ({
globalFilter,
tableEntries,
selectedEntries: highlightedEntries,
pinnedEntries,
onChange,
}: OrderbookTableProps) => {
const { t } = useTranslation()
@ -230,10 +244,14 @@ export const OrderbookTable = ({
bottom: [],
})
const table = useReactTable({
const table = useReactTable<OrderTableEntry>({
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),
@ -243,6 +261,7 @@ export const OrderbookTable = ({
rowSelection,
columnVisibility,
},
globalFilterFn: 'fuzzy' as FilterFnOption<OrderTableEntry>,
keepPinnedRows: true,
enableRowSelection: true,
onSortingChange: setSorting,
@ -253,7 +272,6 @@ export const OrderbookTable = ({
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualFiltering: true, // we filter before passing to table
})
useEffect(() => {
@ -302,6 +320,12 @@ export const OrderbookTable = ({
}
}
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">
@ -385,7 +409,7 @@ export const OrderbookTable = ({
</Table>
</div>
<Pagination
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
itemsPerPage={itemsPerPage}

View file

@ -0,0 +1,30 @@
import { cva } from 'class-variance-authority'
export const buttonVariants = cva(
"inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
'ghost-navbar': 'hover:bg-zinc-700 light:hover:bg-zinc-200',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)

View file

@ -1,36 +1,8 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { type VariantProps, cva } from 'class-variance-authority'
import { type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
'ghost-navbar': 'hover:bg-zinc-700 light:hover:bg-zinc-200',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
import { buttonVariants } from './button-variants'
function Button({
className,

View file

@ -0,0 +1,124 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Pagination,
PaginationContent,
PaginationFirst,
PaginationItem,
PaginationLast,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '../pagination'
const DEFAULT_PAGE_SIZES = [25, 50, 100]
const DEFAULT_ALLOW_SHOW_ALL = true
interface TablePaginationProps {
currentPage: number
totalPages: number
itemsPerPage: number
totalItems: number
pageSizes?: number[]
allowShowAll?: boolean
onPageChange: (page: number) => void
onItemsPerPageChange: (itemsPerPage: number) => void
className?: string
}
const TablePagination = ({
currentPage,
totalPages,
itemsPerPage,
totalItems,
onPageChange,
onItemsPerPageChange,
className,
pageSizes = DEFAULT_PAGE_SIZES,
allowShowAll = DEFAULT_ALLOW_SHOW_ALL,
}: TablePaginationProps) => {
const { t } = useTranslation()
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
onPageChange(page)
}
}
const handleItemsPerPageChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onItemsPerPageChange(parseInt(e.target.value, 10))
}
const isShowingAll = itemsPerPage === -1
const startItem = isShowingAll ? 1 : (currentPage - 1) * itemsPerPage + 1
const endItem = isShowingAll ? totalItems : Math.min(currentPage * itemsPerPage, totalItems)
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-2 px-2 py-4 sm:flex-row sm:justify-between',
className,
)}
>
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
<span>{t('global.table.pagination.items_per_page.label')}</span>
<select
value={itemsPerPage}
onChange={handleItemsPerPageChange}
className="border-input bg-background rounded border px-2 py-1 text-sm"
>
{pageSizes.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
{allowShowAll && (
<option value={-1}>{t('global.table.pagination.items_per_page.text_option_show_all')}</option>
)}
</select>
</div>
<div className="flex items-center gap-2">
{totalItems > 0 ? (
<div className="hidden text-sm font-medium text-nowrap sm:block">
{`${startItem}-${endItem} of ${totalItems}`}
</div>
) : undefined}
{!isShowingAll && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationFirst onClick={() => handlePageChange(1)} disabled={currentPage === 1} />
</PaginationItem>
<PaginationItem>
<PaginationPrevious onClick={() => handlePageChange(currentPage - 1)} disabled={currentPage - 1 < 1} />
</PaginationItem>
{[currentPage, currentPage - 1, currentPage + 1, currentPage - 2, currentPage + 2]
.filter((it) => it >= 1 && it <= totalPages)
.sort()
.slice(0, 3)
.map((it, index) => (
<PaginationItem key={index}>
<PaginationLink isActive={it === currentPage} onClick={() => handlePageChange(it)}>
{it}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage + 1 > totalPages}
/>
</PaginationItem>
<PaginationItem>
<PaginationLast onClick={() => handlePageChange(totalPages)} disabled={currentPage === totalPages} />
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</div>
)
}
export { TablePagination }

View file

@ -1,108 +1,119 @@
import * as React from 'react'
import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface PaginationProps {
currentPage: number
totalPages: number
itemsPerPage: number
totalItems: number
onPageChange: (page: number) => void
onItemsPerPageChange: (itemsPerPage: number) => void
className?: string
}
const Pagination = ({
currentPage,
totalPages,
itemsPerPage,
totalItems,
onPageChange,
onItemsPerPageChange,
className,
}: PaginationProps) => {
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
onPageChange(page)
}
}
const handleItemsPerPageChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onItemsPerPageChange(parseInt(e.target.value, 10))
}
const isShowingAll = itemsPerPage === -1
const startItem = isShowingAll ? 1 : (currentPage - 1) * itemsPerPage + 1
const endItem = isShowingAll ? totalItems : Math.min(currentPage * itemsPerPage, totalItems)
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<div className={cn('flex items-center justify-between px-2 py-4', className)}>
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
<span>Items per page</span>
<select
value={itemsPerPage}
onChange={handleItemsPerPageChange}
className="border-input bg-background rounded border px-2 py-1 text-sm"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={-1}>All</option>
</select>
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="text-sm font-medium">
{totalItems > 0 ? `${startItem}-${endItem} of ${totalItems}` : '0-0 of 0'}
</div>
{!isShowingAll && (
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className="hidden lg:flex"
>
<ChevronsLeftIcon />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeftIcon />
</Button>
<div className="flex items-center justify-center text-sm font-medium">{currentPage}</div>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronRightIcon />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
className="hidden lg:flex"
>
<ChevronsRightIcon />
</Button>
</div>
)}
</div>
</div>
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
)
}
export { Pagination }
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
return <ul data-slot="pagination-content" className={cn('flex flex-row items-center gap-1', className)} {...props} />
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'button'>
function PaginationLink({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) {
return (
<Button
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
size={size}
variant={isActive ? 'outline' : 'ghost'}
className={className}
{...props}
/>
)
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
const { t } = useTranslation()
return (
<PaginationLink
aria-label={t('global.table.pagination.page_selector.label_previous')}
title={t('global.table.pagination.page_selector.label_previous')}
size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">{t('global.table.pagination.page_selector.label_previous')}</span>
</PaginationLink>
)
}
function PaginationFirst({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
const { t } = useTranslation()
return (
<PaginationLink
aria-label={t('global.table.pagination.page_selector.label_first')}
title={t('global.table.pagination.page_selector.label_first')}
size="default"
className={cn('gap-1 px-2.5 md:pr-2.5', className)}
{...props}
>
<ChevronsLeftIcon />
<span className="hidden md:block">{t('global.table.pagination.page_selector.label_first')}</span>
</PaginationLink>
)
}
function PaginationLast({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
const { t } = useTranslation()
return (
<PaginationLink
aria-label={t('global.table.pagination.page_selector.label_last')}
title={t('global.table.pagination.page_selector.label_last')}
size="default"
className={cn('gap-1 px-2.5 md:pr-2.5', className)}
{...props}
>
<span className="hidden md:block">{t('global.table.pagination.page_selector.label_last')}</span>
<ChevronsRightIcon />
</PaginationLink>
)
}
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
const { t } = useTranslation()
return (
<PaginationLink
aria-label={t('global.table.pagination.page_selector.label_next')}
title={t('global.table.pagination.page_selector.label_next')}
size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}
>
<span className="hidden sm:block">{t('global.table.pagination.page_selector.label_next')}</span>
<ChevronRightIcon />
</PaginationLink>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationFirst,
PaginationLast,
}