mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
chore(send): jars with frozen funds are selectable (#1370)
* chore(send): remove dev/prod distinction for minimum collaborators this is already dynamically done by fetching the backend config value * chore(send): jars with frozen funds are selectable * ui(send): justify jars and increase space to precondition alert * build: fix errors in test setups * feat(send): open utxo selector on click on selected jar * test: add tests for Jar and SelectableJar * chore(ui): frozen balance with class 'invisible' instead of 'hidden' * ui(receive): justify around jars * chore(i18n): improve missing source jar utxos message
This commit is contained in:
parent
d5b38740c7
commit
bce1df33e9
37 changed files with 393 additions and 111 deletions
|
|
@ -53,7 +53,7 @@ vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
vi.mock('./ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
|
||||
<span onClick={onClick}>{valueString}</span>
|
||||
),
|
||||
|
|
@ -74,6 +74,7 @@ const makeJar = (jarIndex: number, name: string): Jar =>
|
|||
calculatedTotalBalanceInSats: 100,
|
||||
calculatedAvailableBalanceInSats: 100,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
},
|
||||
}) as unknown as Jar
|
||||
|
||||
|
|
|
|||
|
|
@ -53,16 +53,16 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
vi.mock('@/components/ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({
|
||||
name,
|
||||
onClick,
|
||||
onSelect,
|
||||
disabled,
|
||||
isSelected,
|
||||
}: {
|
||||
name?: string
|
||||
onClick?: () => void
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
isSelected?: boolean
|
||||
}) => (
|
||||
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
|
||||
<button onSelect={onSelect} disabled={disabled} data-selected={isSelected}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ vi.mock('../ui/jam/Address', () => ({
|
|||
Address: ({ value }: { value?: string }) => <span data-testid="address">{value}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { useMemo, type PropsWithChildren } from 'react'
|
|||
import { ClockIcon, CoinsIcon, CopyIcon } from 'lucide-react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
import { time } from '@/lib/utils'
|
||||
import { Address } from '../ui/jam/Address'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
|
||||
interface FidelityBondCardProps {
|
||||
value: FidelityBondUtxo
|
||||
|
|
|
|||
|
|
@ -72,16 +72,16 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
vi.mock('@/components/ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({
|
||||
name,
|
||||
onClick,
|
||||
onSelect,
|
||||
disabled,
|
||||
isSelected,
|
||||
}: {
|
||||
name?: string
|
||||
onClick?: () => void
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
isSelected?: boolean
|
||||
}) => (
|
||||
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
|
||||
<button onClick={onSelect} disabled={disabled} data-selected={isSelected}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ vi.mock('@/lib/utils', () => ({
|
|||
isRelativeOffer: (type: string) => type === 'sw0relo',
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function FidelityBondJarSelector({ selectedJarIndex, onSelect, isJarDisab
|
|||
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
|
||||
isSelected={selectedJarIndex === jar.jarIndex}
|
||||
onClick={() => onSelect(jar.jarIndex)}
|
||||
onSelect={() => onSelect(jar.jarIndex)}
|
||||
disabled={isJarDisabled(jar)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { Link, useNavigate, type NavigateFunction } from 'react-router-dom'
|
||||
import { DevBadge } from '@/components/dev/DevBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { ThemeToggleButton } from '@/components/ui/jam/ThemeToggleButton'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { SidebarContextProps } from '@/components/ui/use-sidebar'
|
||||
|
|
@ -15,7 +16,6 @@ import type { RescanInfo } from '@/context/JamSessionInfoContext'
|
|||
import { cn, shortenStringMiddle } from '@/lib/utils'
|
||||
import type { AmountSats } from '@/types/global'
|
||||
import { WithActivityIndicator } from '../ui/jam/ActivityIndicator'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
|
@ -36,7 +37,6 @@ import {
|
|||
} from '@/lib/utils'
|
||||
import { useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
import { jmSessionStore } from '@/store/jmSessionStore'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
import { OrderbookChart } from './OrderbookChart'
|
||||
import { OrderbookTable, type OrderTableEntry } from './OrderbookTable'
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({ name, isSelected, onClick }: { name: string; isSelected: boolean; onClick: () => void }) => (
|
||||
<button type="button" aria-pressed={isSelected} onClick={onClick}>
|
||||
SelectableJar: ({ name, isSelected, onSelect }: { name: string; isSelected: boolean; onSelect: () => void }) => (
|
||||
<button type="button" aria-pressed={isSelected} onClick={onSelect}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export const ReceiveForm = ({ className, defaultValues, onSubmit, jars, disabled
|
|||
>
|
||||
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
|
||||
<FieldLabel>{t('receive.label_source_jar')}</FieldLabel>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="flex flex-1 flex-row flex-wrap items-start justify-around gap-8">
|
||||
{jars.map((jar, index) => (
|
||||
<SelectableJar
|
||||
key={index}
|
||||
|
|
@ -102,7 +102,7 @@ export const ReceiveForm = ({ className, defaultValues, onSubmit, jars, disabled
|
|||
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
|
||||
isSelected={values.source?.fromJar === jar.jarIndex}
|
||||
onClick={() => {
|
||||
onSelect={() => {
|
||||
setValue('source.fromJar', jar.jarIndex, { shouldValidate: true })
|
||||
}}
|
||||
disabled={disabled || isSubmitting}
|
||||
|
|
|
|||
|
|
@ -11,16 +11,16 @@ vi.mock('react-i18next', () => ({
|
|||
vi.mock('../ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({
|
||||
name,
|
||||
onClick,
|
||||
onSelect,
|
||||
disabled,
|
||||
isSelected,
|
||||
}: {
|
||||
name?: string
|
||||
onClick?: () => void
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
isSelected?: boolean
|
||||
}) => (
|
||||
<button type="button" onClick={onClick} disabled={disabled} data-selected={isSelected}>
|
||||
<button type="button" onClick={onSelect} disabled={disabled} data-selected={isSelected}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export default function JarSelectorDialog({
|
|||
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
|
||||
isSelected={selectedJarIndex === jar.jarIndex}
|
||||
onClick={() =>
|
||||
onSelect={() =>
|
||||
setValue('jarIndex', jar.jarIndex, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ vi.mock('../ui/jam/Address', () => ({
|
|||
Address: ({ value }: { value: string }) => <span>address:{value}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString: string }) => <span>balance:{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
@ -50,6 +50,7 @@ const sourceJar: Jar = {
|
|||
calculatedAvailableBalanceInSats: 50_000,
|
||||
calculatedTotalBalanceInSats: 50_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 50_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
},
|
||||
color: '#e2b86a',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { InfoIcon } from 'lucide-react'
|
|||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { jarBadgeVariant } from '@/components/ui/badge-variants'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import type { Jar } from '@/context/JamWalletInfoContext'
|
||||
import type { Utxo } from '@/hooks/useQueryUtxos'
|
||||
import type { JamFeeConfigValues } from '@/lib/feeConfig'
|
||||
|
|
@ -13,7 +14,6 @@ import { Button } from '../ui/button'
|
|||
import { Card, CardContent, CardHeader } from '../ui/card'
|
||||
import { Dialog, DialogTitle, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '../ui/dialog'
|
||||
import { Address } from '../ui/jam/Address'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { estimateMaxCollaboratorFee, useMiningFeeText, type EstimateMaxCollaboratorFeeResult } from './feeEstimate'
|
||||
|
|
|
|||
|
|
@ -64,11 +64,28 @@ export const createSendFormSchema = (
|
|||
.object({
|
||||
source: yup
|
||||
.object({
|
||||
fromJar: sourceJarField(
|
||||
t('send.feedback_invalid_source_jar'),
|
||||
(jarIndex) =>
|
||||
(jars.find((it) => it.jarIndex === jarIndex)?.balanceSummary.calculatedAvailableBalanceInSats || 0) > 0,
|
||||
),
|
||||
fromJar: sourceJarField(t('send.feedback_invalid_source_jar'), (jarIndex) =>
|
||||
jars.some((it) => it.jarIndex === jarIndex),
|
||||
)
|
||||
.test(
|
||||
'valid-source-jar-must-unfreeze-utxos-test',
|
||||
t('send.feedback_invalid_source_jar_must_unfreeze_utxos'),
|
||||
(jarIndex) => {
|
||||
const jar = jars.find((it) => it.jarIndex === jarIndex)
|
||||
if (!jar) return true
|
||||
if (jar.balanceSummary.calculatedAvailableBalanceInSats > 0) return true
|
||||
return jar.balanceSummary.calculatedAvailableFrozenBalanceInSats <= 0
|
||||
},
|
||||
)
|
||||
.test(
|
||||
'valid-source-jar-has-funds-test',
|
||||
t('send.feedback_invalid_source_jar_no_available_utxos'),
|
||||
(jarIndex) => {
|
||||
const jar = jars.find((it) => it.jarIndex === jarIndex)
|
||||
if (!jar) return true
|
||||
return jar.balanceSummary.calculatedAvailableBalanceInSats > 0
|
||||
},
|
||||
),
|
||||
})
|
||||
.required(),
|
||||
destination: yup
|
||||
|
|
|
|||
|
|
@ -55,13 +55,13 @@ vi.mock('../ui/jam/Address', () => ({
|
|||
Address: () => <div data-testid="address" />,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString?: string }) => <div data-testid="balance">{valueString}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({ name, onClick, disabled }: { name?: string; onClick?: () => void; disabled?: boolean }) => (
|
||||
<button onClick={onClick} disabled={disabled}>
|
||||
SelectableJar: ({ name, onSelect, disabled }: { name?: string; onSelect?: () => void; disabled?: boolean }) => (
|
||||
<button onClick={onSelect} disabled={disabled}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import { getAddressInfo, Network } from 'bitcoin-address-validation'
|
|||
import type { AddressInfo } from 'bitcoin-address-validation'
|
||||
import { AlertTriangleIcon, BrushCleaningIcon, MilkIcon, ScanQrCodeIcon, XIcon } from 'lucide-react'
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form'
|
||||
import type { Resolver, SubmitHandler } from 'react-hook-form'
|
||||
import type { FieldErrors, Resolver, SubmitHandler } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import QrScannerDialog from '@/components/ui/QrScannerDialog'
|
||||
import { isDevMode } from '@/constants/debugFeatures'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { MAX_NUM_COLLABORATORS } from '@/constants/jam'
|
||||
import { JM_MINIMUM_MAKERS_DEFAULT, JM_TAKER_UTXO_AGE } from '@/constants/jm'
|
||||
import { useDetectNetwork, type AddressSummary, type Jar } from '@/context/JamWalletInfoContext'
|
||||
|
|
@ -31,7 +31,6 @@ import { Field, FieldDescription, FieldError, FieldLabel } from '../ui/field'
|
|||
import { Input } from '../ui/input'
|
||||
import { inputVariants } from '../ui/input-variants'
|
||||
import { Address } from '../ui/jam/Address'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
import { SatSymbol } from '../ui/jam/CurrencySymbol'
|
||||
import { SelectableJar } from '../ui/jam/SelectableJar'
|
||||
import { Slider } from '../ui/slider'
|
||||
|
|
@ -94,12 +93,6 @@ const AddressFromJarSelectorDialog = ({
|
|||
)
|
||||
}
|
||||
|
||||
// set the default to one collaborator in dev mode
|
||||
const DEV_INITIAL_NUM_COLLABORATORS_INPUT = 1
|
||||
|
||||
// TODO: this value should be dynamic via jm backend settings
|
||||
const MIN_NUM_COLLABORATORS = isDevMode() ? DEV_INITIAL_NUM_COLLABORATORS_INPUT : JM_MINIMUM_MAKERS_DEFAULT
|
||||
|
||||
const FieldPrefixSatSymbol = (
|
||||
<SatSymbol
|
||||
width={'18px'}
|
||||
|
|
@ -114,7 +107,8 @@ interface SendFormProps {
|
|||
className?: string
|
||||
onSubmit: SubmitHandler<SendFormValues>
|
||||
onSourceJarChange?: (jarIndex: JarIndex | undefined) => void
|
||||
sourceJarLabelButton?: React.ReactElement
|
||||
onSourceJarClicked?: (jarIndex: JarIndex | undefined) => void
|
||||
sourceJarLabelButton?: (errors?: FieldErrors<{ fromJar: number }>) => React.ReactElement
|
||||
minNumberOfCollaborators?: number
|
||||
feeConfigValues: JamFeeConfigValues
|
||||
walletFileName: WalletFileName
|
||||
|
|
@ -129,11 +123,12 @@ export function SendForm({
|
|||
className,
|
||||
onSubmit,
|
||||
onSourceJarChange,
|
||||
onSourceJarClicked,
|
||||
sourceJarLabelButton,
|
||||
disabled,
|
||||
feeConfigValues,
|
||||
walletFileName,
|
||||
minNumberOfCollaborators = MIN_NUM_COLLABORATORS,
|
||||
minNumberOfCollaborators = JM_MINIMUM_MAKERS_DEFAULT,
|
||||
jars,
|
||||
walletBalanceSummary,
|
||||
addressSummary,
|
||||
|
|
@ -204,6 +199,15 @@ export function SendForm({
|
|||
onSourceJarChange?.(sourceJarIndex)
|
||||
}, [onSourceJarChange, sourceJarIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (formState.dirtyFields.source?.fromJar) {
|
||||
console.debug(
|
||||
'Validating `source.fromJar` as wallet balance summary changed (e.g. frozen/unfrozen utxos, new transaction, etc.)',
|
||||
)
|
||||
void trigger('source.fromJar')
|
||||
}
|
||||
}, [walletBalanceSummary, trigger, formState.dirtyFields.source?.fromJar])
|
||||
|
||||
const destinationJar = useMemo(() => {
|
||||
if (destinationJarIndex === undefined) return
|
||||
return jars.find((it) => it.jarIndex === destinationJarIndex)
|
||||
|
|
@ -300,41 +304,58 @@ export function SendForm({
|
|||
{...sendFormMethods}
|
||||
>
|
||||
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
|
||||
<div className="space-y-2">
|
||||
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
|
||||
<div className="space-y-4">
|
||||
<Field className="space-y-4" data-invalid={errors.source?.fromJar !== undefined}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<FieldLabel>{t('send.label_source_jar')}</FieldLabel>
|
||||
{sourceJarLabelButton && <>{sourceJarLabelButton}</>}
|
||||
{sourceJarLabelButton?.(errors.source)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-row flex-wrap items-center justify-center gap-8">
|
||||
{jars.map((jar, index) => (
|
||||
<SelectableJar
|
||||
key={index}
|
||||
name={jar.name}
|
||||
color={jar.color}
|
||||
totalBalance={jar.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={jar.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
|
||||
isSelected={sourceJarIndex === jar.jarIndex}
|
||||
onClick={() => {
|
||||
setValue('source.fromJar', jar.jarIndex, { shouldValidate: true })
|
||||
<div className="flex flex-1 flex-row flex-wrap items-start justify-around gap-8">
|
||||
{jars.map((jar, index) => {
|
||||
const noFunds =
|
||||
jar.balanceSummary.calculatedAvailableBalanceInSats <= 0 &&
|
||||
jar.balanceSummary.calculatedAvailableFrozenBalanceInSats <= 0
|
||||
|
||||
if (isSweep === true) {
|
||||
setValue('amount.isSweep', false, { shouldValidate: true })
|
||||
setValue('amount.sweepAmount', undefined, { shouldValidate: true })
|
||||
setValue('amount.amount', undefined, { shouldValidate: true })
|
||||
}
|
||||
if (destinationJarIndex === jar.jarIndex) {
|
||||
setValue('destination.address', '', { shouldValidate: true })
|
||||
setValue('destination.fromJar', undefined, { shouldValidate: true })
|
||||
} else if (destinationAddress !== undefined) {
|
||||
void trigger('destination.address')
|
||||
}
|
||||
}}
|
||||
disabled={disabled || jar.balanceSummary.calculatedAvailableBalanceInSats <= 0}
|
||||
/>
|
||||
))}
|
||||
const isSelected = sourceJarIndex === jar.jarIndex
|
||||
|
||||
return (
|
||||
<SelectableJar
|
||||
key={index}
|
||||
name={jar.name}
|
||||
color={jar.color}
|
||||
totalBalance={jar.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={jar.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onSourceJarClicked?.(sourceJarIndex)
|
||||
}
|
||||
}}
|
||||
onSelect={() => {
|
||||
setValue('source.fromJar', jar.jarIndex, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
|
||||
if (isSweep === true) {
|
||||
setValue('amount.isSweep', false, { shouldValidate: true })
|
||||
setValue('amount.sweepAmount', undefined, { shouldValidate: true })
|
||||
setValue('amount.amount', undefined, { shouldValidate: true })
|
||||
}
|
||||
if (destinationJarIndex === jar.jarIndex) {
|
||||
setValue('destination.address', '', { shouldValidate: true })
|
||||
setValue('destination.fromJar', undefined, { shouldValidate: true })
|
||||
} else if (destinationAddress !== undefined) {
|
||||
void trigger('destination.address')
|
||||
}
|
||||
}}
|
||||
disabled={disabled || noFunds}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{errors.source?.fromJar?.message ? <FieldError>{errors.source?.fromJar.message}</FieldError> : null}
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -209,11 +209,11 @@ vi.mock('./SendForm', () => ({
|
|||
disabled?: boolean
|
||||
onSourceJarChange?: (jarIndex: number | undefined) => void
|
||||
onSubmit: (values: SendFormValues) => void
|
||||
sourceJarLabelButton?: React.ReactElement
|
||||
sourceJarLabelButton?: () => React.ReactElement
|
||||
}) => (
|
||||
<div>
|
||||
send-form:{String(disabled)}
|
||||
<div>{sourceJarLabelButton}</div>
|
||||
<div>{sourceJarLabelButton?.()}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSourceJarChange?.(0)
|
||||
|
|
@ -241,6 +241,7 @@ vi.mock('./UtxoSelectionDialog', () => ({
|
|||
const balanceSummary = {
|
||||
calculatedAvailableBalanceInSats: 20_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 20_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
calculatedTotalBalanceInSats: 20_000,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import type { UtxoId } from '@/hooks/useQueryUtxos'
|
|||
import { useRefreshSession } from '@/hooks/useRefreshSession'
|
||||
import { useUtxoSelectionDialog } from '@/hooks/useUtxoSelectionDialog'
|
||||
import { getErrorReason } from '@/lib/errorReason'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
import { withMutationDelay } from '@/lib/queryClient'
|
||||
import { scrollToTop, type WalletFileName } from '@/lib/utils'
|
||||
import { useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
|
|
@ -114,15 +115,20 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
return jars.find((it) => it.jarIndex === sourceJarIndex)
|
||||
}, [jars, sourceJarIndex])
|
||||
|
||||
const utxoSelectionDialog = useUtxoSelectionDialog({
|
||||
const { onOpenUtxoSelector, ...utxoSelectionDialog } = useUtxoSelectionDialog({
|
||||
walletFileName,
|
||||
sourceJar,
|
||||
addressSummary,
|
||||
})
|
||||
|
||||
const availableUtxosForPayment = useMemo(() => {
|
||||
return (sourceJar?.utxos || []).filter((utxo) => !utxo.frozen).toSorted((a, b) => a.confirmations - b.confirmations)
|
||||
}, [sourceJar])
|
||||
const isCoinJoin = sendFromValuesAwaitingConfirmation?.isCoinJoin ?? true // assume true
|
||||
return (sourceJar?.utxos || [])
|
||||
.filter((utxo) => !utxo.frozen)
|
||||
.filter((utxo) => !fb.utxo.isLocked(utxo))
|
||||
.filter((utxo) => (isCoinJoin ? true : !fb.utxo.isFidelityBond(utxo)))
|
||||
.toSorted((a, b) => a.confirmations - b.confirmations)
|
||||
}, [sourceJar, sendFromValuesAwaitingConfirmation?.isCoinJoin])
|
||||
|
||||
const destinationJar = useMemo(() => {
|
||||
const destinationJarIndex = sendFromValuesAwaitingConfirmation?.destination?.fromJar
|
||||
|
|
@ -604,17 +610,23 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
}
|
||||
debug={isDeveloperMode}
|
||||
onSourceJarChange={setSourceJarIndex}
|
||||
sourceJarLabelButton={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={utxoSelectionDialog.utxoSelectorDisabled}
|
||||
onClick={utxoSelectionDialog.onOpenUtxoSelector}
|
||||
>
|
||||
<ListFilterIcon />
|
||||
{t('show_utxos.text_select_utxos_tooltip')}
|
||||
</Button>
|
||||
}
|
||||
onSourceJarClicked={onOpenUtxoSelector}
|
||||
sourceJarLabelButton={(errors) => {
|
||||
const errorSolvedByUtxoSelection = errors?.fromJar?.type === 'valid-source-jar-must-unfreeze-utxos-test'
|
||||
const animate = utxoSelectionDialog.dialogProps.open === false && errorSolvedByUtxoSelection
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={animate ? 'animate-shake' : undefined}
|
||||
disabled={utxoSelectionDialog.utxoSelectorDisabled}
|
||||
onClick={onOpenUtxoSelector}
|
||||
>
|
||||
<ListFilterIcon className={animate ? 'motion-safe:animate-bounce' : undefined} />
|
||||
{t('show_utxos.text_select_utxos_tooltip')}
|
||||
</Button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
|
|||
calculatedAvailableBalanceInSats: 100_000,
|
||||
calculatedTotalBalanceInSats: 100_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 100_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
},
|
||||
color: '#e2b86a',
|
||||
|
|
@ -315,6 +316,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
|
|||
calculatedAvailableBalanceInSats: 0,
|
||||
calculatedTotalBalanceInSats: 0,
|
||||
calculatedConfirmedAvailableBalanceInSats: 0,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
},
|
||||
color: '#ccc',
|
||||
|
|
@ -341,6 +343,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
|
|||
calculatedAvailableBalanceInSats: 100_000,
|
||||
calculatedTotalBalanceInSats: 100_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 100_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
},
|
||||
walletName: 'wallet.jmdat',
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import '@testing-library/jest-dom/vitest'
|
|||
import { render as reactRender, screen, type RenderOptions } from '@testing-library/react'
|
||||
import user from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { JamDisplayContextProvider } from '@/context/JamDisplayContextProvider'
|
||||
import { Balance } from './Balance'
|
||||
|
||||
const render = (ui: React.ReactNode, options?: Omit<RenderOptions, 'queries'>) => {
|
||||
const providers = ({ children }: { children: React.ReactNode }) => {
|
||||
|
|
|
|||
70
src/components/ui/jam/Jar.test.tsx
Normal file
70
src/components/ui/jam/Jar.test.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { type Jar as JarType } from '@/context/JamWalletInfoContext'
|
||||
import type { AmountSats } from '@/types/global'
|
||||
import { Jar } from './Jar'
|
||||
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
|
||||
<span onClick={onClick}>balance:{valueString}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
const DUMMY_JAR: JarType = {
|
||||
balanceSummary: {
|
||||
calculatedTotalBalanceInSats: 5,
|
||||
calculatedAvailableBalanceInSats: 4,
|
||||
calculatedConfirmedAvailableBalanceInSats: 3,
|
||||
calculatedAvailableFrozenBalanceInSats: 2,
|
||||
calculatedFrozenOrLockedBalanceInSats: 1,
|
||||
},
|
||||
color: '#666',
|
||||
jarIndex: 0,
|
||||
name: 'Test jar',
|
||||
utxos: [],
|
||||
}
|
||||
|
||||
describe('Jar', () => {
|
||||
it('renders correctly', () => {
|
||||
render(
|
||||
<Jar
|
||||
name={DUMMY_JAR.name}
|
||||
color={DUMMY_JAR.color}
|
||||
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
|
||||
isSelected={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats).parentNode,
|
||||
).not.toHaveClass('invisible')
|
||||
})
|
||||
it('does not render zero frozen balance', () => {
|
||||
const frozenBalance: AmountSats = 0
|
||||
|
||||
render(
|
||||
<Jar
|
||||
name={DUMMY_JAR.name}
|
||||
color={DUMMY_JAR.color}
|
||||
frozenOrLockedBalance={frozenBalance}
|
||||
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
|
||||
isSelected={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument()
|
||||
expect(screen.getByText('balance:' + frozenBalance)).toBeInTheDocument()
|
||||
expect(screen.getByText('balance:' + frozenBalance).parentNode).toHaveClass('invisible')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import type { JarColor } from '@/context/JamWalletInfoContext'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AmountSats } from '@/types/global'
|
||||
import { Balance } from './Balance'
|
||||
import { JarIcon } from './JarIcon'
|
||||
|
||||
interface JarProps {
|
||||
|
|
@ -53,7 +53,7 @@ export function Jar({
|
|||
</div>
|
||||
<div
|
||||
className={cn('text-brand-info flex min-w-[110px] items-center justify-center gap-1 text-xs', {
|
||||
hidden: frozenOrLockedBalance <= 0,
|
||||
invisible: frozenOrLockedBalance <= 0,
|
||||
})}
|
||||
>
|
||||
<Balance valueString={String(frozenOrLockedBalance)} frozen={true} />
|
||||
|
|
|
|||
131
src/components/ui/jam/SelectableJar.test.tsx
Normal file
131
src/components/ui/jam/SelectableJar.test.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach } from 'vitest'
|
||||
import type { Jar } from '@/context/JamWalletInfoContext'
|
||||
import { SelectableJar } from './SelectableJar'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
onClick: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
|
||||
<span onClick={onClick}>balance:{valueString}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
const DUMMY_JAR: Jar = {
|
||||
balanceSummary: {
|
||||
calculatedTotalBalanceInSats: 5,
|
||||
calculatedAvailableBalanceInSats: 4,
|
||||
calculatedConfirmedAvailableBalanceInSats: 3,
|
||||
calculatedAvailableFrozenBalanceInSats: 2,
|
||||
calculatedFrozenOrLockedBalanceInSats: 1,
|
||||
},
|
||||
color: '#666',
|
||||
jarIndex: 0,
|
||||
name: 'Test jar',
|
||||
utxos: [],
|
||||
}
|
||||
|
||||
describe('SelectableJar', () => {
|
||||
beforeEach(() => {
|
||||
mocks.onClick.mockReset()
|
||||
mocks.onSelect.mockReset()
|
||||
})
|
||||
|
||||
it('renders correctly', () => {
|
||||
render(
|
||||
<SelectableJar
|
||||
name={DUMMY_JAR.name}
|
||||
color={DUMMY_JAR.color}
|
||||
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
|
||||
isSelected={false}
|
||||
onSelect={mocks.onSelect}
|
||||
onClick={mocks.onClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('can be selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<SelectableJar
|
||||
name={DUMMY_JAR.name}
|
||||
color={DUMMY_JAR.color}
|
||||
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
|
||||
isSelected={false}
|
||||
onSelect={mocks.onSelect}
|
||||
onClick={mocks.onClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(mocks.onClick).toHaveBeenCalled()
|
||||
expect(mocks.onSelect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('can not be selected if disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<SelectableJar
|
||||
name={DUMMY_JAR.name}
|
||||
color={DUMMY_JAR.color}
|
||||
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
|
||||
isSelected={false}
|
||||
onSelect={mocks.onSelect}
|
||||
onClick={mocks.onClick}
|
||||
disabled={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(mocks.onClick).not.toHaveBeenCalled()
|
||||
expect(mocks.onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onSelect if already selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<SelectableJar
|
||||
name={DUMMY_JAR.name}
|
||||
color={DUMMY_JAR.color}
|
||||
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
|
||||
isSelected={true}
|
||||
onSelect={mocks.onSelect}
|
||||
onClick={mocks.onClick}
|
||||
disabled={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(mocks.onClick).toHaveBeenCalled()
|
||||
expect(mocks.onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -4,7 +4,8 @@ import { Jar } from './Jar'
|
|||
|
||||
interface SelectableJarProps extends ComponentProps<typeof Jar> {
|
||||
isSelected: NonNullable<React.ComponentProps<'input'>['checked']>
|
||||
onClick: NonNullable<React.ComponentProps<'input'>['onChange']>
|
||||
onClick?: NonNullable<React.ComponentProps<'input'>['onClick']>
|
||||
onSelect: NonNullable<React.ComponentProps<'input'>['onChange']>
|
||||
}
|
||||
|
||||
export const SelectableJar = ({
|
||||
|
|
@ -16,20 +17,19 @@ export const SelectableJar = ({
|
|||
totalWalletBalance,
|
||||
isSelected,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
onClick,
|
||||
}: SelectableJarProps) => {
|
||||
const radioRef = useRef<HTMLInputElement>(null)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn('flex flex-col items-center gap-4', {
|
||||
className={cn('flex flex-col items-center gap-1.5', {
|
||||
'cursor-pointer': !disabled,
|
||||
'cursor-not-allowed': disabled,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (radioRef.current !== null) {
|
||||
radioRef.current.click()
|
||||
}
|
||||
radioRef.current?.click()
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
|
|
@ -49,7 +49,8 @@ export const SelectableJar = ({
|
|||
ref={radioRef}
|
||||
type="radio"
|
||||
checked={isSelected}
|
||||
onChange={(event) => !disabled && onClick(event)}
|
||||
onClick={onClick}
|
||||
onChange={(event) => !disabled && onSelect(event)}
|
||||
className={cn(
|
||||
'light:border-black/50 inline-block h-[1.5rem] w-[1.5rem] appearance-none rounded-full border-1 border-white/50',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ vi.mock('../ui/jam/Address', () => ({
|
|||
Address: ({ value }: { value: string }) => <span>{value}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type Table as TableType,
|
||||
} from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { TablePagination } from '@/components/ui/jam/TablePagination'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import type { AccountBranch } from '@/context/JamWalletInfoContext'
|
||||
|
|
@ -26,7 +27,6 @@ import type { UtxoTag } from '@/lib/tags'
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { AmountSats, BitcoinAddress, HdPath } from '@/types/global'
|
||||
import { Address } from '../ui/jam/Address'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
import { SortIcon } from '../ui/jam/SortIcon'
|
||||
import { StatusBadge } from '../ui/jam/StatusBadge'
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ vi.mock('../ui/jam/Address', () => ({
|
|||
Address: ({ value }: { value: string }) => <span>{value}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type { TFunction } from 'i18next'
|
|||
import { ChevronDownIcon, SnowflakeIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { TablePagination } from '@/components/ui/jam/TablePagination'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import type { Utxo } from '@/hooks/useQueryUtxos'
|
||||
|
|
@ -38,7 +39,6 @@ import { Button } from '../ui/button'
|
|||
import { Card, CardContent } from '../ui/card'
|
||||
import { Checkbox } from '../ui/checkbox'
|
||||
import { Address } from '../ui/jam/Address'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
import { SortIcon } from '../ui/jam/SortIcon'
|
||||
import { StatusBadge } from '../ui/jam/StatusBadge'
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ vi.mock('../ui/jam/Address', () => ({
|
|||
Address: ({ value }: { value: string }) => <span>{value}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
@ -82,6 +82,7 @@ const jars: Jar[] = [
|
|||
calculatedAvailableBalanceInSats: 12_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 12_000,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedTotalBalanceInSats: 12_000,
|
||||
},
|
||||
color: '#e2b86a',
|
||||
|
|
@ -94,6 +95,7 @@ const jars: Jar[] = [
|
|||
calculatedAvailableBalanceInSats: 5_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 5_000,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedTotalBalanceInSats: 5_000,
|
||||
},
|
||||
color: '#3b5ba9',
|
||||
|
|
@ -106,6 +108,7 @@ const jars: Jar[] = [
|
|||
calculatedAvailableBalanceInSats: 8_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 8_000,
|
||||
calculatedFrozenOrLockedBalanceInSats: 8_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 8_000,
|
||||
calculatedTotalBalanceInSats: 8_000,
|
||||
},
|
||||
color: '#5ba93b',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { TFunction } from 'i18next'
|
|||
import { AlertTriangleIcon, RefreshCwIcon, ThermometerSnowflakeIcon, ThermometerSunIcon } from 'lucide-react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext'
|
||||
import {
|
||||
useAccountSummary,
|
||||
|
|
@ -26,7 +27,6 @@ import { Alert, AlertDescription, AlertTitle } from '../ui/alert'
|
|||
import { Button } from '../ui/button'
|
||||
import { ButtonGroup } from '../ui/button-group'
|
||||
import { Input } from '../ui/input'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'
|
||||
import { AccountDetailsTabContent } from './AccountDetailsTabContent'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { freezeMutation } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
|
@ -116,7 +116,7 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma
|
|||
},
|
||||
})
|
||||
|
||||
const onOpenUtxoSelector = () => {
|
||||
const onOpenUtxoSelector = useCallback(() => {
|
||||
if (!sourceJar) return
|
||||
toast.dismiss(SEND_AUTO_SELECTION_TOAST_ID)
|
||||
reset({
|
||||
|
|
@ -124,7 +124,7 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma
|
|||
rowSelection: initialRowSelection,
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
}, [sourceJar, reset, initialRowSelection])
|
||||
|
||||
const onSubmit = handleSubmit(async ({ rowSelection: submittedRowSelection }) => {
|
||||
if (!sourceJar) return
|
||||
|
|
@ -239,7 +239,7 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma
|
|||
shouldValidate: true,
|
||||
})
|
||||
},
|
||||
onSubmit: async () => onSubmit(),
|
||||
onSubmit,
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -440,6 +440,8 @@
|
|||
"qr_scan_file_no_qr": "No QR code found in the selected image.",
|
||||
"qr_scan_bip21_applied": "Payment request applied.",
|
||||
"feedback_invalid_source_jar": "Please select a jar to send from.",
|
||||
"feedback_invalid_source_jar_must_unfreeze_utxos": "Please select UTXOs from the source jar.",
|
||||
"feedback_invalid_source_jar_no_available_utxos": "Please select a jar with spendable balance to send from.",
|
||||
"feedback_invalid_destination_address": "Please enter a valid destination address.",
|
||||
"feedback_destination_network_mismatch": "This address does not match your wallet's network.",
|
||||
"feedback_reused_address": "This address is already used. To preserve your privacy please choose another one.",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ describe('BalanceSummary', () => {
|
|||
calculatedTotalBalanceInSats: 11,
|
||||
calculatedAvailableBalanceInSats: 6,
|
||||
calculatedConfirmedAvailableBalanceInSats: 1,
|
||||
calculatedAvailableFrozenBalanceInSats: 3,
|
||||
calculatedFrozenOrLockedBalanceInSats: 5,
|
||||
})
|
||||
})
|
||||
|
|
@ -86,6 +87,7 @@ describe('BalanceSummary', () => {
|
|||
calculatedTotalBalanceInSats: 677777777,
|
||||
calculatedAvailableBalanceInSats: 333333333,
|
||||
calculatedConfirmedAvailableBalanceInSats: 111111111,
|
||||
calculatedAvailableFrozenBalanceInSats: 344444444,
|
||||
calculatedFrozenOrLockedBalanceInSats: 344444444,
|
||||
})
|
||||
})
|
||||
|
|
@ -136,6 +138,7 @@ describe('BalanceSummary', () => {
|
|||
calculatedTotalBalanceInSats: 11,
|
||||
calculatedAvailableBalanceInSats: 5,
|
||||
calculatedConfirmedAvailableBalanceInSats: 0,
|
||||
calculatedAvailableFrozenBalanceInSats: 3,
|
||||
calculatedFrozenOrLockedBalanceInSats: 6,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ export type BalanceSummary = {
|
|||
* @description Manually calculated confirmed available balance in sats.
|
||||
*/
|
||||
calculatedConfirmedAvailableBalanceInSats: AmountSats
|
||||
/**
|
||||
* @description Manually calculated frozen balance in sats, that can be unfrozen.
|
||||
*/
|
||||
calculatedAvailableFrozenBalanceInSats: AmountSats
|
||||
/**
|
||||
* @description Manually calculated frozen or locked balance in sats.
|
||||
*/
|
||||
|
|
@ -26,6 +30,7 @@ export const BALANCE_SUMMARY_EMPTY: BalanceSummary = {
|
|||
calculatedTotalBalanceInSats: 0,
|
||||
calculatedAvailableBalanceInSats: 0,
|
||||
calculatedConfirmedAvailableBalanceInSats: 0,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
}
|
||||
|
||||
|
|
@ -34,14 +39,17 @@ export const toBalanceSummary = (utxos: Utxo[], now?: Milliseconds): BalanceSumm
|
|||
return utxos
|
||||
.map((utxo) => {
|
||||
const isFidelityBond = fb.utxo.isFidelityBond(utxo)
|
||||
const frozenOrLocked = utxo.frozen || (isFidelityBond && fb.utxo.isLocked(utxo, refTime))
|
||||
const isLocked = isFidelityBond && fb.utxo.isLocked(utxo, refTime)
|
||||
const frozen = utxo.frozen && !isLocked
|
||||
const frozenOrLocked = frozen || isLocked
|
||||
const available = !frozenOrLocked
|
||||
return {
|
||||
total: utxo.value,
|
||||
available: available ? utxo.value : 0,
|
||||
confirmedAvailable: available && utxo.confirmations > 0 ? utxo.value : 0,
|
||||
frozenOrLocked: frozenOrLocked ? utxo.value : 0,
|
||||
bond: fb.utxo.isFidelityBond(utxo) ? utxo.value : 0,
|
||||
frozen: frozen ? utxo.value : 0,
|
||||
bond: isFidelityBond ? utxo.value : 0,
|
||||
}
|
||||
})
|
||||
.reduce(
|
||||
|
|
@ -50,6 +58,7 @@ export const toBalanceSummary = (utxos: Utxo[], now?: Milliseconds): BalanceSumm
|
|||
calculatedAvailableBalanceInSats: acc.calculatedAvailableBalanceInSats + info.available,
|
||||
calculatedConfirmedAvailableBalanceInSats:
|
||||
acc.calculatedConfirmedAvailableBalanceInSats + info.confirmedAvailable,
|
||||
calculatedAvailableFrozenBalanceInSats: acc.calculatedAvailableFrozenBalanceInSats + info.frozen,
|
||||
calculatedFrozenOrLockedBalanceInSats: acc.calculatedFrozenOrLockedBalanceInSats + info.frozenOrLocked,
|
||||
}),
|
||||
BALANCE_SUMMARY_EMPTY,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export const Selected: Story = {
|
|||
frozenOrLockedBalance: 0,
|
||||
totalWalletBalance: 500_000,
|
||||
isSelected: true,
|
||||
onSelect: () => alert('Selected'),
|
||||
onClick: () => alert('Selected jar clicked'),
|
||||
},
|
||||
parameters: {
|
||||
|
|
@ -97,6 +98,7 @@ export const Unselected: Story = {
|
|||
frozenOrLockedBalance: 0,
|
||||
totalWalletBalance: 500_000,
|
||||
isSelected: false,
|
||||
onSelect: () => alert('Selected'),
|
||||
onClick: () => alert('Unselected jar clicked'),
|
||||
},
|
||||
parameters: {
|
||||
|
|
@ -118,6 +120,7 @@ export const EmptyJar: Story = {
|
|||
frozenOrLockedBalance: 0,
|
||||
totalWalletBalance: 500_000,
|
||||
isSelected: false,
|
||||
onSelect: () => alert('Selected'),
|
||||
onClick: () => alert('Empty jar clicked'),
|
||||
},
|
||||
parameters: {
|
||||
|
|
@ -139,6 +142,7 @@ export const FullJar: Story = {
|
|||
frozenOrLockedBalance: 0,
|
||||
totalWalletBalance: 500_000,
|
||||
isSelected: false,
|
||||
onSelect: () => alert('Selected'),
|
||||
onClick: () => alert('Full jar clicked'),
|
||||
},
|
||||
parameters: {
|
||||
|
|
@ -161,6 +165,7 @@ export const DisabledJar: Story = {
|
|||
totalWalletBalance: 500_000,
|
||||
isSelected: false,
|
||||
disabled: true,
|
||||
onSelect: () => alert('Selected'),
|
||||
onClick: () => alert('Disabled jar clicked'),
|
||||
},
|
||||
parameters: {
|
||||
|
|
@ -182,6 +187,7 @@ export const JarGrid: Story = {
|
|||
frozenOrLockedBalance: 0,
|
||||
totalWalletBalance: 500_000,
|
||||
isSelected: true,
|
||||
onSelect: () => alert('Selected'),
|
||||
onClick: () => alert('Jar clicked'),
|
||||
},
|
||||
parameters: {
|
||||
|
|
@ -216,7 +222,7 @@ export const JarGrid: Story = {
|
|||
totalWalletBalance={totalWalletBalance}
|
||||
isSelected={jar.isSelected}
|
||||
disabled={jar.disabled}
|
||||
onClick={() => alert(`Clicked on ${jar.name}`)}
|
||||
onSelect={() => alert(`Clicked on ${jar.name}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -235,6 +241,7 @@ export const Interactive: Story = {
|
|||
frozenOrLockedBalance: 100_000,
|
||||
totalWalletBalance: 500_000,
|
||||
isSelected: false,
|
||||
onSelect: () => alert('Selected'),
|
||||
onClick: () => alert('Interactive jar clicked'),
|
||||
},
|
||||
parameters: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue