mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
feat: plan scheduled sweep (#1339)
* refactor: add reason to scheduler error messages * fix: dismiss schedule confirm dialog on error * chore(i18n): distinct scheduler starting/stopping messages * chore(sweep): remove redundant manual destination validation * chore(sweep): initial sweep options * chore(ui): add slider component * chore(sweep): add rounding chance option * feat(sweep): ability to add/remove destinations * chore(sweep): use plan tumbler mutation * refactor(sweep): use tumblerstartMutation * refactor(sweep): externalize SweepForm * feat(sweep): ability to set min/max number of collaborators * chore(sweep): increase time_lambda_seconds in insecure test options * chore(sweep): fix progress step of quick sweep * chore(sweep): start refactor schedule entry * chore(sweep): preserve raw data in ScheduleEntry * feat(sweep): show destination addresses * chore(sweep): scroll to top after start/stop * chore(ui): add Item component * fix: only abort single taker runs on send page * chore(sweep): schedule entries with Item component * chore(sweep): show wait time before next action * chore(sweep): schedule entries in accordion * chore(sweep): remove scheduler.progress_current_state_wait_before_next message * chore(sweep): tab for completed entries * refactor(sweep): align Schedule object structure * chore(sweep): display error message on failed actions * chore(sweep): display completed scheduled sweep * chore(sweep): move stop button from child component to page * feat(sweep): ability to review scheduled sweep plan * chore(sweep): increase schedule polling from 3s to 10s * chore(sweep): hourglass icon for waiting alerts * chore(sweep): adapt buttons, labels and alert texts * fix(sweep): default min confirmations between phases * chore(sweep): improve time estimate on sweeps * chore(sweep): show pending tasks in details * feat(sweep): ability to configure mintxcount * chore(storybook): add slider, sidebar and item stories * test(sweep): verify add/remove sweep destination input * test: load decorators from story meta config * chore(sweep): add makeSessionIdleTimeoutSeconds to form values * chore(sweep): reload schedule if not stale or terminated * chore(sweep): more details in sections * chore(sweep): reorder entry details * chore(sweep): show transaction info in destination section * chore(sweep): estimate duration with fixed runtime info of maker phases * chore(regtest): setup dummy wallet fidelity bonds * fix(sweep): maker idle timeout less than runtime duration * chore(sweep): use taker_utxo_age as min confs in sweep preconditions * chore(build): add task test:coverage * chore(sweep): max number of destination addresses * chore(sweep): maxNumberOfDestinations
This commit is contained in:
parent
f9523729ae
commit
64ecd7ad77
57 changed files with 4688 additions and 976 deletions
6
.env
6
.env
|
|
@ -6,9 +6,15 @@ VITE_JAM_JM_SESSION_REFRESH_INTERVAL=30000
|
|||
VITE_JAM_RESCAN_PROGRESS_INTERVAL=10000
|
||||
VITE_JAM_SEED_MODAL_TIMEOUT=30000
|
||||
|
||||
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=3
|
||||
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
|
||||
|
||||
VITE_JM_WEBSOCKET_ENDPOINT_PATH=/jmws
|
||||
VITE_JM_WEBSOCKET_CONNECTION_HEALTHY_DURATION=1000
|
||||
VITE_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DURATION=3000
|
||||
VITE_JM_WEBSOCKET_KEEPALIVE_MESSAGE_INTERVAL=30000
|
||||
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN=5000
|
||||
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=60000
|
||||
|
||||
VITE_JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT=10
|
||||
VITE_JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT=90
|
||||
|
|
|
|||
|
|
@ -5,3 +5,11 @@ VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN=3000
|
|||
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=30000
|
||||
|
||||
VITE_JAM_SEED_MODAL_TIMEOUT=21000
|
||||
|
||||
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=59
|
||||
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=59
|
||||
|
||||
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1
|
||||
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
|
||||
|
||||
VITE_JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS=1
|
||||
|
|
|
|||
5
.env.test
Normal file
5
.env.test
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1
|
||||
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
|
||||
|
||||
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=1
|
||||
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=1
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
run: npm run format:check
|
||||
|
||||
- name: Test
|
||||
run: npm test -- --coverage.enabled
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { initialize, mswLoader } from 'msw-storybook-addon'
|
|||
import { ThemeProvider } from 'next-themes'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { CoreTypes, GlobalTypes } from 'storybook/internal/csf'
|
||||
import type { CoreTypes, GlobalTypes } from 'storybook/internal/csf'
|
||||
import { DEFAULT_VIEWPORT, MINIMAL_VIEWPORTS, INITIAL_VIEWPORTS } from 'storybook/viewport'
|
||||
import { JamDisplayContextProvider } from '../src/context/JamDisplayContextProvider'
|
||||
import { JamWalletInfoContextProvider } from '../src/context/JamWalletInfoContextProvider'
|
||||
|
|
|
|||
|
|
@ -35,10 +35,14 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
|
|||
dummy_wallet_address1='bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk' # 1st address of jar A (m/84'/1'/0'/0/0)
|
||||
dummy_wallet_address2='bcrt1qt5yxk3xzrx66q9wd5sdyynklqynqcyf7uh74j3' # 8th address of jar C (m/84'/1'/2'/0/7)
|
||||
dummy_wallet_address3='bcrt1qn8804dw5fahuc5cwqteuq5j4xlhk2cnkq7a8kw' # 21st change address of jar E (m/84'/1'/4'/1/21)
|
||||
dummy_wallet_address_fb0='bcrt1q7war3lusq3ez633rzzfkp75unfcgrqqcu80sgp0ellhrdvkzxh5swspygk' # 1st fb address (m/84'/1'/0'/2/0)
|
||||
dummy_wallet_address_fb959='bcrt1q6rlcknptmqhf4w20928tpz6px4y70960gyupd029c7vx7kdjwt0q46nk9c' # 960th fb address (m/84'/1'/0'/2/959)
|
||||
# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106
|
||||
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address1" &>/dev/null
|
||||
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address2" &>/dev/null
|
||||
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address3" &>/dev/null
|
||||
. "$script_dir/mine-block.sh" 1 "$dummy_wallet_address_fb0" &>/dev/null
|
||||
. "$script_dir/mine-block.sh" 1 "$dummy_wallet_address_fb959" &>/dev/null
|
||||
. "$script_dir/mine-block.sh" 100 &>/dev/null
|
||||
|
||||
start_maker() {
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
|||
"@radix-ui/react-radio-group": "1.4.3",
|
||||
"@radix-ui/react-select": "2.3.3",
|
||||
"@radix-ui/react-separator": "1.1.11",
|
||||
"@radix-ui/react-slider": "1.4.3",
|
||||
"@radix-ui/react-slot": "1.3.0",
|
||||
"@radix-ui/react-switch": "1.3.3",
|
||||
"@radix-ui/react-tabs": "1.1.17",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"test": "NODE_OPTIONS=--no-webstorage vitest run",
|
||||
"test:unit": "NODE_OPTIONS=--no-webstorage vitest run --project unit",
|
||||
"test:storybook": "NODE_OPTIONS=--no-webstorage vitest run --project storybook",
|
||||
"test:coverage": "npm test -- --coverage.enabled",
|
||||
"check": "npm run format && npm run test && npm run build",
|
||||
"regtest:build": "npm run regtest:clear && npm run regtest:pull && docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml build --pull",
|
||||
"regtest:pull": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml pull",
|
||||
|
|
@ -83,6 +84,7 @@
|
|||
"@radix-ui/react-radio-group": "1.4.3",
|
||||
"@radix-ui/react-select": "2.3.3",
|
||||
"@radix-ui/react-separator": "1.1.11",
|
||||
"@radix-ui/react-slider": "1.4.3",
|
||||
"@radix-ui/react-slot": "1.3.0",
|
||||
"@radix-ui/react-switch": "1.3.3",
|
||||
"@radix-ui/react-tabs": "1.1.17",
|
||||
|
|
|
|||
|
|
@ -220,13 +220,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
<div className="mx-auto max-w-4xl space-y-3 p-4">
|
||||
<PageTitle title={t('earn.title')} subtitle={t('earn.subtitle')} />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => setShowEarnReport(true)}>
|
||||
<FileTextIcon />
|
||||
{t('earn.button_show_report')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{feeConfigValidation.maxFeesConfigMissing && (
|
||||
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
|
||||
)}
|
||||
|
|
@ -234,7 +227,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
{jmSession.coinjoin_in_process === true && (
|
||||
<Alert variant="warning">
|
||||
<HourglassIcon className="motion-safe:animate-pulse" />
|
||||
<AlertDescription>{t('send.text_coinjoin_already_running')}</AlertDescription>
|
||||
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
|
@ -263,6 +256,13 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => setShowEarnReport(true)}>
|
||||
<FileTextIcon />
|
||||
{t('earn.button_show_report')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{jmSession.offer_list && jmSession.offer_list.length > 0 && (
|
||||
<OfferCard
|
||||
className="motion-safe:animate-in blur-in"
|
||||
|
|
|
|||
|
|
@ -20,9 +20,8 @@ interface UseJmwalletdStdoutLogParameters {
|
|||
}
|
||||
|
||||
export function useJmwalletdStdoutLog({ enabled = true }: UseJmwalletdStdoutLogParameters = {}) {
|
||||
const authState = useStore(authStore, (state) => state.state)
|
||||
const { t } = useTranslation()
|
||||
const token = authState?.auth?.token
|
||||
const token = useStore(authStore, (state) => state.state?.auth?.token)
|
||||
|
||||
const {
|
||||
refetch: logQueryRefetch,
|
||||
|
|
|
|||
|
|
@ -236,9 +236,10 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
|
|||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="options">
|
||||
<AccordionTrigger>{t('receive.button_settings')}</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4">
|
||||
<AccordionContent
|
||||
className={cn('flex flex-col gap-6 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
|
||||
>
|
||||
<ReceiveForm
|
||||
className={'mx-1' /* add x-spacing for input component focus state*/}
|
||||
defaultValues={receiveFormDefaultValues}
|
||||
jars={jars}
|
||||
disabled={getAddressMutation.isPending}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { toast } from 'sonner'
|
|||
import QrScannerDialog from '@/components/ui/QrScannerDialog'
|
||||
import { isDevMode } from '@/constants/debugFeatures'
|
||||
import { MAX_NUM_COLLABORATORS } from '@/constants/jam'
|
||||
import { JM_MINIMUM_MAKERS_DEFAULT } from '@/constants/jm'
|
||||
import { JM_MINIMUM_MAKERS_DEFAULT, JM_TAKER_UTXO_AGE } from '@/constants/jm'
|
||||
import { useDetectNetwork, type AddressSummary, type Jar } from '@/context/JamWalletInfoContext'
|
||||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
import type { BalanceSummary } from '@/lib/balanceSummary'
|
||||
|
|
@ -176,7 +176,7 @@ export function SendForm({
|
|||
[formState.errors, formState.isValid, formState.isSubmitting],
|
||||
)
|
||||
const collapsibleFormElementsValid = useMemo(
|
||||
() => errors.numCollaborators === undefined && errors.txFee === undefined,
|
||||
() => [errors.numCollaborators, errors.txFee].every((it) => it === undefined),
|
||||
[errors.numCollaborators, errors.txFee],
|
||||
)
|
||||
|
||||
|
|
@ -212,7 +212,9 @@ export function SendForm({
|
|||
|
||||
const coinjoinPreconditionSummary = useMemo(() => {
|
||||
if (!sourceJar) return undefined
|
||||
return buildSweepPreconditionSummary(sourceJar.utxos)
|
||||
return buildSweepPreconditionSummary(sourceJar.utxos, {
|
||||
minConfirmations: JM_TAKER_UTXO_AGE,
|
||||
})
|
||||
}, [sourceJar])
|
||||
|
||||
const hasCoinjoinPreconditionWarning = isCoinJoin && coinjoinPreconditionSummary?.isFulfilled === false
|
||||
|
|
@ -253,9 +255,7 @@ export function SendForm({
|
|||
|
||||
const handleAddressPaste = useCallback(
|
||||
(event: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = event.clipboardData.getData('text')
|
||||
if (!pasted.toLowerCase().startsWith('bitcoin:')) return
|
||||
|
||||
const pasted = event.clipboardData.getData('text') ?? ''
|
||||
const parsed = parseBip21Uri(pasted)
|
||||
if (!parsed) return
|
||||
|
||||
|
|
@ -561,7 +561,7 @@ export function SendForm({
|
|||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent
|
||||
className={cn('flex flex-col gap-6', 'mx-1' /* add x-spacing for input component focus state*/)}
|
||||
className={cn('flex flex-col gap-6 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({
|
|||
startCoinjoin: vi.fn(),
|
||||
stopCoinjoinRefetch: vi.fn(),
|
||||
takerRunning: false,
|
||||
schedulerRunning: false,
|
||||
toastError: vi.fn(),
|
||||
toastInfo: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
|
|
@ -160,6 +161,9 @@ vi.mock('@/context/JamSessionInfoContext', () => ({
|
|||
takerInfo: {
|
||||
currentPaymentAttempt: mocks.takerRunning || mocks.currentPaymentAttemptPresent ? collaborativeValues : undefined,
|
||||
running: mocks.takerRunning,
|
||||
scheduler: {
|
||||
running: mocks.schedulerRunning,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
|
@ -318,6 +322,7 @@ describe('SendPage', () => {
|
|||
mocks.stopCoinjoinRefetch.mockReset()
|
||||
mocks.stopCoinjoinRefetch.mockResolvedValue({ data: {} })
|
||||
mocks.takerRunning = false
|
||||
mocks.schedulerRunning = false
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastInfo.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
|
|
@ -384,6 +389,7 @@ describe('SendPage', () => {
|
|||
it('shows the running CoinJoin state and confirms abort', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.takerRunning = true
|
||||
mocks.schedulerRunning = false
|
||||
|
||||
render(<SendPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
|
|
@ -397,6 +403,17 @@ describe('SendPage', () => {
|
|||
expect(mocks.stopCoinjoinRefetch).toHaveBeenCalledWith({ throwOnError: true })
|
||||
})
|
||||
|
||||
it('shows the running CoinJoin state with abort button not present', () => {
|
||||
mocks.takerRunning = true
|
||||
mocks.schedulerRunning = true
|
||||
|
||||
render(<SendPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument()
|
||||
expect(screen.getByText('send-form:true')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'global.abort' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('confirms and starts a collaborative transaction', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.startCoinjoin.mockResolvedValue({})
|
||||
|
|
|
|||
|
|
@ -83,7 +83,11 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
} = useJamWalletInfoContext()
|
||||
const jmSession = useStore(jmSessionStore, (state) => state.state)
|
||||
const {
|
||||
takerInfo: { running: takerRunning, currentPaymentAttempt },
|
||||
takerInfo: {
|
||||
running: takerRunning,
|
||||
currentPaymentAttempt,
|
||||
scheduler: { running: schedulerRunning },
|
||||
},
|
||||
rescanInfo,
|
||||
setCurrentPaymentAttempt,
|
||||
clearCurrentPaymentAttempt,
|
||||
|
|
@ -494,7 +498,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
}
|
||||
{takerRunning && !isWaitingCoinjoinStop && (
|
||||
<Alert variant="warning">
|
||||
<HourglassIcon />
|
||||
<HourglassIcon className="motion-safe:animate-pulse" />
|
||||
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
{currentPaymentAttempt && (
|
||||
|
|
@ -516,15 +520,17 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
)}
|
||||
</pre>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAbortCoinjoinDialog(true)}
|
||||
disabled={isWaitingCoinjoinStop}
|
||||
>
|
||||
{t('global.abort')}
|
||||
</Button>
|
||||
</div>
|
||||
{takerRunning && !schedulerRunning ? (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAbortCoinjoinDialog(true)}
|
||||
disabled={isWaitingCoinjoinStop}
|
||||
>
|
||||
{t('global.abort')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
|
|||
233
src/components/sweep/ScheduleEntryItem.tsx
Normal file
233
src/components/sweep/ScheduleEntryItem.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import {
|
||||
AlertTriangleIcon,
|
||||
CalendarCheck2Icon,
|
||||
CalendarClockIcon,
|
||||
ClockFadingIcon,
|
||||
CopyCheckIcon,
|
||||
CopyIcon,
|
||||
ExternalLinkIcon,
|
||||
FingerprintIcon,
|
||||
MilkIcon,
|
||||
TimerResetIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { jarBadgeVariant, type BadgeVariant } from '../ui/badge-variants'
|
||||
import { buttonVariants } from '../ui/button-variants'
|
||||
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from '../ui/item'
|
||||
import { Address } from '../ui/jam/Address'
|
||||
import { CopyButton } from '../ui/jam/CopyButton'
|
||||
import { Label } from '../ui/label'
|
||||
import { formatDuration, type ScheduleEntry } from './scheduleUtils'
|
||||
|
||||
const EntryStatusBadge = ({ active, status }: { active: boolean; status: ScheduleEntry['status'] }) => {
|
||||
const variant: BadgeVariant = (() => {
|
||||
if (active) return 'outline'
|
||||
switch (status.value) {
|
||||
case 'completed':
|
||||
return 'success'
|
||||
case 'failed':
|
||||
return 'destructive'
|
||||
case 'cancelled':
|
||||
return 'warning'
|
||||
case 'skipped':
|
||||
return 'outline'
|
||||
default:
|
||||
return 'muted'
|
||||
}
|
||||
})()
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn({
|
||||
'ring-ring/50 ring-1 motion-safe:animate-pulse': active,
|
||||
})}
|
||||
>
|
||||
{/* TODO: i18n */ status.value}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export const ScheduleEntryItem = ({ value, active }: { value: ScheduleEntry; active: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Item
|
||||
variant="outline"
|
||||
className={cn({
|
||||
'ring-brand-success ring-1': value.status.completed,
|
||||
'ring-destructive ring-1': value.status.failed,
|
||||
'ring-muted-foreground ring-1': value.status.skipped,
|
||||
'ring-ring/50 ring-2': active,
|
||||
'bg-muted text-muted-foreground': value.derivedStatus.terminated,
|
||||
})}
|
||||
>
|
||||
<ItemHeader>
|
||||
<div>
|
||||
<ItemTitle
|
||||
className={cn({
|
||||
'text-destructive': value.status.failed,
|
||||
'text-brand-warning': value.status.cancelled,
|
||||
'text-muted-foreground': value.status.skipped,
|
||||
})}
|
||||
>
|
||||
{t('scheduler.progress_entry_label', { index: (value.index + 1).toLocaleString() })}
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
{
|
||||
/* TODO: i18n */
|
||||
value.kind === 'taker_coinjoin' ? (
|
||||
<Trans
|
||||
i18nKey="A collaborative transaction as <1>taker</1> with {{numberOfRequestedCounterparties}} counterparties"
|
||||
values={{ numberOfRequestedCounterparties: value.numberOfRequestedCounterparties?.toLocaleString() }}
|
||||
components={{
|
||||
'1': <span className="font-semibold" />,
|
||||
}}
|
||||
/>
|
||||
) : value.kind === 'maker_session' ? (
|
||||
<Trans
|
||||
i18nKey="A collaborative transaction as <1>maker</1>"
|
||||
components={{
|
||||
'1': <span className="font-semibold" />,
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
</ItemDescription>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse flex-wrap items-center gap-2">
|
||||
<EntryStatusBadge active={active} status={value.status} />
|
||||
{value.kind === 'maker_session' ? <Badge variant="info">{/* TODO: i18n */ 'Earn'}</Badge> : null}
|
||||
{value.kind === 'taker_coinjoin' ? <Badge variant="default">{/* TODO: i18n */ 'Send'}</Badge> : null}
|
||||
{value.isSweep ? <Badge variant="outline">{/* TODO: i18n */ 'Sweep'}</Badge> : null}
|
||||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{value.__raw?.error ? (
|
||||
<div className="text-destructive col-span-full flex min-w-0 items-start gap-4">
|
||||
<AlertTriangleIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'Error'}</Label>
|
||||
{value.__raw?.error ?? t('global.errors.reason_unknown')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{value.startedAt ? (
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<CalendarClockIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'Started At'}</Label>
|
||||
<span title={value.startedAt.toISOString()}>{value.startedAt.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{value.startedAt || value.finishedAt ? (
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<CalendarCheck2Icon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'Finished At'}</Label>
|
||||
{value.finishedAt === undefined ? (
|
||||
'-'
|
||||
) : (
|
||||
<span title={value.finishedAt.toISOString()}>{value.finishedAt.toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{value.jar ? (
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<MilkIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'Source Jar'}</Label>
|
||||
<Badge variant={jarBadgeVariant(value.jar.jarIndex)}>
|
||||
{value.jar.name} <span className="text-xs">#{value.jar.jarIndex.toLocaleString()}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{value.numberOfRequestedCounterparties ? (
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<UsersIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'Request collaborators'}</Label>
|
||||
{value.numberOfRequestedCounterparties.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/*value.__raw.durationSeconds ? (
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<ClockFadingIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="font-semibold">{'Duration'}</Label>
|
||||
<span>{value.__raw.durationSeconds}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null*/}
|
||||
|
||||
{value.idleTimeoutSeconds ? (
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<ClockFadingIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="font-semibold">{/* TODO: i18n*/ 'Idle Timeout'}</Label>
|
||||
{formatDuration(value.idleTimeoutSeconds, t)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{value.waitTimeInSeconds ? (
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<TimerResetIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="font-semibold">{t('scheduler.progress_entry_wait_before_next_title')}</Label>
|
||||
{value.waitTimeInSeconds <= 0 ? '-' : formatDuration(value.waitTimeInSeconds, t)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{value.externalDestinationAddress ? (
|
||||
<div className="col-span-full flex min-w-0 items-start gap-4">
|
||||
<ExternalLinkIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'External destination'}</Label>
|
||||
<Address value={value.externalDestinationAddress} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{value.transactionId ? (
|
||||
<div className="col-span-full flex min-w-0 items-start gap-4">
|
||||
<FingerprintIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'Transaction ID'}</Label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-md block font-mono break-all select-all">{value.transactionId}</span>
|
||||
<CopyButton
|
||||
value={value.transactionId}
|
||||
text={
|
||||
<>
|
||||
<CopyIcon />
|
||||
{t('global.button_copy_text')}
|
||||
</>
|
||||
}
|
||||
successText={
|
||||
<>
|
||||
<CopyCheckIcon />
|
||||
{t('global.button_copy_text_confirmed')}
|
||||
</>
|
||||
}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'xs' }), 'shrink-0')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SweepDestinationInputs } from './SweepDestinationInputs'
|
||||
|
|
@ -20,6 +21,7 @@ describe('SweepDestinationInputs', () => {
|
|||
isSubmitted: false,
|
||||
touchedFields: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
register: vi.fn((name: string) => ({ name, onBlur: vi.fn(), onChange: vi.fn(), ref: vi.fn() })),
|
||||
} as unknown as SweepForm
|
||||
|
||||
|
|
@ -28,8 +30,17 @@ describe('SweepDestinationInputs', () => {
|
|||
{ id: '2', address: '' },
|
||||
] as unknown as SweepFields
|
||||
|
||||
it('renders input fields correctly', () => {
|
||||
render(<SweepDestinationInputs form={defaultFormMock} fields={fields} disabled={false} />)
|
||||
it('renders input fields correctly', async () => {
|
||||
render(
|
||||
<SweepDestinationInputs
|
||||
minNumberOfFields={fields.length}
|
||||
maxNumberOfFields={fields.length}
|
||||
formState={defaultFormMock.formState}
|
||||
setValue={defaultFormMock.setValue}
|
||||
register={defaultFormMock.register}
|
||||
fields={fields}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should render 2 inputs
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
|
|
@ -42,16 +53,159 @@ describe('SweepDestinationInputs', () => {
|
|||
// Inputs should not be disabled
|
||||
expect(inputs[0]).not.toBeDisabled()
|
||||
expect(inputs[1]).not.toBeDisabled()
|
||||
|
||||
// QR-Code scan button is present
|
||||
const qrCodeButtons = await screen.findAllByRole('button', { name: 'send.qr_scan_title' })
|
||||
expect(qrCodeButtons.length).toBe(fields.length)
|
||||
})
|
||||
|
||||
it('qr-code scanner opens', () => {
|
||||
render(
|
||||
<SweepDestinationInputs
|
||||
minNumberOfFields={fields.length}
|
||||
maxNumberOfFields={fields.length}
|
||||
formState={defaultFormMock.formState}
|
||||
setValue={defaultFormMock.setValue}
|
||||
register={defaultFormMock.register}
|
||||
fields={fields}
|
||||
/>,
|
||||
)
|
||||
|
||||
// QR-Code scan button is present
|
||||
const qrCodeButtons = screen.queryAllByRole('button', { name: 'send.qr_scan_title' })
|
||||
expect(qrCodeButtons.length).toBe(fields.length)
|
||||
|
||||
// CR-Code scanner not active yet
|
||||
expect(screen.queryByText('send.qr_paste_button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('send.qr_scan_file_button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('send.confirm_button_reject')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(qrCodeButtons[0])
|
||||
|
||||
expect(screen.queryByText('send.qr_paste_button')).toBeInTheDocument()
|
||||
expect(screen.queryByText('send.qr_scan_file_button')).toBeInTheDocument()
|
||||
expect(screen.queryByText('modal.confirm_button_reject')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders disabled input fields', () => {
|
||||
render(<SweepDestinationInputs form={defaultFormMock} fields={fields} disabled={true} />)
|
||||
render(
|
||||
<SweepDestinationInputs
|
||||
minNumberOfFields={fields.length}
|
||||
maxNumberOfFields={fields.length}
|
||||
formState={defaultFormMock.formState}
|
||||
setValue={defaultFormMock.setValue}
|
||||
register={defaultFormMock.register}
|
||||
fields={fields}
|
||||
disabled={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs[0]).toBeDisabled()
|
||||
expect(inputs[1]).toBeDisabled()
|
||||
})
|
||||
|
||||
it('handles bip21 format from paste events', async () => {
|
||||
render(
|
||||
<SweepDestinationInputs
|
||||
minNumberOfFields={fields.length}
|
||||
maxNumberOfFields={fields.length}
|
||||
formState={defaultFormMock.formState}
|
||||
setValue={defaultFormMock.setValue}
|
||||
register={defaultFormMock.register}
|
||||
fields={fields}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
await act(async () => {
|
||||
await userEvent.click(inputs[0])
|
||||
await userEvent.paste('anything')
|
||||
})
|
||||
expect(defaultFormMock.setValue).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(inputs[0])
|
||||
await userEvent.paste('bitcoin:')
|
||||
})
|
||||
expect(defaultFormMock.setValue).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(inputs[1])
|
||||
await userEvent.paste(
|
||||
'bitcoin:bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk?amount=0.0021&label=order%20123&message=regtest',
|
||||
)
|
||||
})
|
||||
expect(defaultFormMock.setValue).toHaveBeenCalledWith(
|
||||
`destinations.1.address`,
|
||||
'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk',
|
||||
{ shouldValidate: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('adds additional destination inputs', () => {
|
||||
const maxNumberOfFields = 3
|
||||
const { rerender } = render(
|
||||
<SweepDestinationInputs
|
||||
minNumberOfFields={1}
|
||||
maxNumberOfFields={maxNumberOfFields}
|
||||
formState={defaultFormMock.formState}
|
||||
setValue={defaultFormMock.setValue}
|
||||
register={defaultFormMock.register}
|
||||
fields={fields}
|
||||
onClickAppend={() => {
|
||||
const id = fields.map((it) => it.id).reduce((acc, i) => acc + Number(i), 0) + 1
|
||||
fields.push({ id: String(id), address: '' })
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs.length).toBe(2)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add additional destination' })).not.toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add additional destination' }))
|
||||
|
||||
rerender(
|
||||
<SweepDestinationInputs
|
||||
minNumberOfFields={1}
|
||||
maxNumberOfFields={3}
|
||||
formState={defaultFormMock.formState}
|
||||
setValue={defaultFormMock.setValue}
|
||||
register={defaultFormMock.register}
|
||||
fields={fields}
|
||||
onClickRemove={() => fields.pop()}
|
||||
onClickAppend={() => {
|
||||
/* empty on purpose; wont be called*/
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputsAfterAdd = screen.getAllByRole('textbox')
|
||||
expect(inputsAfterAdd.length).toBe(maxNumberOfFields)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add additional destination' })).toBeDisabled()
|
||||
|
||||
const clearButtons = screen.queryAllByRole('button', { name: 'global.clear' })
|
||||
expect(clearButtons.length).toBe(fields.length - 1)
|
||||
|
||||
fireEvent.click(clearButtons[0])
|
||||
|
||||
rerender(
|
||||
<SweepDestinationInputs
|
||||
minNumberOfFields={1}
|
||||
maxNumberOfFields={3}
|
||||
formState={defaultFormMock.formState}
|
||||
setValue={defaultFormMock.setValue}
|
||||
register={defaultFormMock.register}
|
||||
fields={fields}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputsAfterClear = screen.getAllByRole('textbox')
|
||||
expect(inputsAfterClear.length).toBe(2)
|
||||
})
|
||||
|
||||
it('shows error messages when submitted and there is an error', () => {
|
||||
const errorFormMock = {
|
||||
...defaultFormMock,
|
||||
|
|
@ -67,7 +221,16 @@ describe('SweepDestinationInputs', () => {
|
|||
},
|
||||
} as unknown as SweepForm
|
||||
|
||||
render(<SweepDestinationInputs form={errorFormMock} fields={fields} disabled={false} />)
|
||||
render(
|
||||
<SweepDestinationInputs
|
||||
minNumberOfFields={fields.length}
|
||||
maxNumberOfFields={fields.length}
|
||||
formState={errorFormMock.formState}
|
||||
setValue={errorFormMock.setValue}
|
||||
register={errorFormMock.register}
|
||||
fields={fields}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Invalid address 1')).toBeInTheDocument()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import { ScanQrCodeIcon } from 'lucide-react'
|
||||
import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form'
|
||||
import { PlusCircleIcon, ScanQrCodeIcon, XIcon } from 'lucide-react'
|
||||
import type { UseFieldArrayReturn, UseFormReturn } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import QrScannerDialog from '@/components/ui/QrScannerDialog'
|
||||
|
|
@ -11,19 +11,32 @@ import { Input } from '@/components/ui/input'
|
|||
import { parseBip21Uri, type Bip21ParseResult } from '@/lib/bip21'
|
||||
import type { SweepFormValues } from './SweepFormSchema'
|
||||
|
||||
type ParentForm = UseFormReturn<SweepFormValues, unknown, SweepFormValues>
|
||||
|
||||
interface SweepDestinationInputsProps {
|
||||
form: UseFormReturn<SweepFormValues, unknown, SweepFormValues>
|
||||
fields: Array<FieldArrayWithId<SweepFormValues, 'destinations', 'id'>>
|
||||
disabled: boolean
|
||||
minNumberOfFields: number
|
||||
maxNumberOfFields: number
|
||||
register: ParentForm['register']
|
||||
setValue: ParentForm['setValue']
|
||||
formState: ParentForm['formState']
|
||||
fields: UseFieldArrayReturn<SweepFormValues, 'destinations', 'id'>['fields']
|
||||
disabled?: boolean
|
||||
onClickAppend?: () => void
|
||||
onClickRemove?: (index: number) => void
|
||||
}
|
||||
|
||||
export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinationInputsProps) => {
|
||||
export const SweepDestinationInputs = ({
|
||||
minNumberOfFields,
|
||||
maxNumberOfFields,
|
||||
setValue,
|
||||
register,
|
||||
formState: { errors, isSubmitted, touchedFields },
|
||||
fields,
|
||||
disabled,
|
||||
onClickAppend,
|
||||
onClickRemove,
|
||||
}: SweepDestinationInputsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
formState: { errors, isSubmitted, touchedFields },
|
||||
register,
|
||||
setValue,
|
||||
} = form
|
||||
|
||||
const [qrScannerIndex, setQrScannerIndex] = useState<number>()
|
||||
|
||||
|
|
@ -36,9 +49,7 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat
|
|||
|
||||
const handleAddressPaste = useCallback(
|
||||
(event: React.ClipboardEvent<HTMLInputElement>, index: number) => {
|
||||
const pasted = event.clipboardData.getData('text')
|
||||
if (!pasted.toLowerCase().startsWith('bitcoin:')) return
|
||||
|
||||
const pasted = event.clipboardData.getData('text') ?? ''
|
||||
const parsed = parseBip21Uri(pasted)
|
||||
if (!parsed) return
|
||||
|
||||
|
|
@ -75,7 +86,7 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat
|
|||
<ButtonGroup className="w-full">
|
||||
<Input
|
||||
id={`sweep-destination-${index}`}
|
||||
{...register(`destinations.${index}.address`)}
|
||||
{...register(`destinations.${index}.address` as const)}
|
||||
className="font-mono"
|
||||
placeholder={t('scheduler.placeholder_destination_input')}
|
||||
disabled={disabled}
|
||||
|
|
@ -83,6 +94,12 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat
|
|||
spellCheck={false}
|
||||
onPaste={(event) => handleAddressPaste(event, index)}
|
||||
/>
|
||||
{index >= minNumberOfFields && onClickRemove ? (
|
||||
<Button type="button" variant="outline" size="lg" onClick={() => onClickRemove(index)}>
|
||||
<XIcon />
|
||||
<span className="sr-only">{t('global.clear')}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
id={`show-qr-scanner-trigger-${index}`}
|
||||
type="button"
|
||||
|
|
@ -101,6 +118,21 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
{onClickAppend ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onClickAppend}
|
||||
disabled={disabled || fields.length >= maxNumberOfFields}
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{/* TODO: i18n */}
|
||||
Add additional destination
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
359
src/components/sweep/SweepForm.tsx
Normal file
359
src/components/sweep/SweepForm.tsx
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { AlertTriangleIcon } from 'lucide-react'
|
||||
import { useFieldArray, useForm, useWatch, type SubmitHandler } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DevBadge } from '@/components/dev/DevBadge'
|
||||
import { SweepDestinationInputs } from '@/components/sweep/SweepDestinationInputs'
|
||||
import {
|
||||
buildSweepDestinationValues,
|
||||
sweepFormSchema,
|
||||
type SweepResolverContext,
|
||||
type SweepFormValues,
|
||||
buildSweepFormValuesDefaultValues,
|
||||
} from '@/components/sweep/SweepFormSchema'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { isDebugFeatureEnabled } from '@/constants/debugFeatures'
|
||||
import {
|
||||
JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT,
|
||||
JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT,
|
||||
JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS,
|
||||
JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS,
|
||||
JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT,
|
||||
JAM_SWEEP_MAX_TRANSACTIONS_PER_JAR,
|
||||
JAM_SWEEP_MIN_MAX_NUMBER_OF_COLLABORATORS,
|
||||
JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS,
|
||||
JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT,
|
||||
JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR,
|
||||
} from '@/constants/jam'
|
||||
import type { AddressSummary, Jar } from '@/context/JamWalletInfoContext'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'
|
||||
import { Card, CardContent, CardHeader } from '../ui/card'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '../ui/field'
|
||||
import { Slider } from '../ui/slider'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
|
||||
const getNewTestingDestinationAddress = (addressSummary: AddressSummary): string => {
|
||||
const newAddressFromDefaultJar =
|
||||
Object.values(addressSummary).find((addressMeta) => addressMeta.status === 'new' && addressMeta.jarIndex === 0)
|
||||
?.address ?? ''
|
||||
if (newAddressFromDefaultJar !== '') {
|
||||
return newAddressFromDefaultJar
|
||||
}
|
||||
|
||||
return Object.values(addressSummary).find((addressMeta) => addressMeta.status === 'new')?.address ?? ''
|
||||
}
|
||||
|
||||
interface SweepFormProps {
|
||||
className?: string
|
||||
onSubmit: SubmitHandler<SweepFormValues>
|
||||
initialValues?: Partial<SweepFormValues>
|
||||
jars: Jar[]
|
||||
addressSummary: AddressSummary
|
||||
disabled?: boolean
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export const SweepForm = ({
|
||||
className,
|
||||
onSubmit,
|
||||
jars,
|
||||
addressSummary,
|
||||
initialValues,
|
||||
disabled,
|
||||
debug,
|
||||
}: SweepFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const showInsecureScheduleTestingToggle = debug && isDebugFeatureEnabled('insecureScheduleTesting')
|
||||
|
||||
const defaultValues = useMemo(() => buildSweepFormValuesDefaultValues(), [])
|
||||
|
||||
const [minNumberOfDestinations] = useState(JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT)
|
||||
const [maxNumberOfDestinations] = useState(Math.max(minNumberOfDestinations, jars.length))
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
sweepFormSchema(
|
||||
{
|
||||
minNumberOfDestinations,
|
||||
maxNumberOfDestinations,
|
||||
addressSummary,
|
||||
},
|
||||
t,
|
||||
),
|
||||
[minNumberOfDestinations, maxNumberOfDestinations, addressSummary, t],
|
||||
)
|
||||
const { formState, reset, register, control, setValue, handleSubmit, trigger } = useForm<
|
||||
SweepFormValues,
|
||||
SweepResolverContext,
|
||||
SweepFormValues
|
||||
>({
|
||||
mode: 'onSubmit',
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
destinations:
|
||||
defaultValues?.destinations ?? buildSweepDestinationValues(JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT),
|
||||
},
|
||||
values: {
|
||||
...defaultValues,
|
||||
...initialValues,
|
||||
},
|
||||
resolver: yupResolver(schema),
|
||||
})
|
||||
|
||||
const formWatch = useWatch({ control })
|
||||
const destinationsFieldArray = useFieldArray({ control, name: 'destinations' })
|
||||
|
||||
const onInsecureTestingToggleChange = (checked: boolean) => {
|
||||
setValue('useInsecureTestingSettings', checked)
|
||||
|
||||
if (checked) {
|
||||
destinationsFieldArray.replace(
|
||||
buildSweepDestinationValues(JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT).map(() => ({
|
||||
address: getNewTestingDestinationAddress(addressSummary),
|
||||
})),
|
||||
)
|
||||
setValue('minNumberOfCollaborators', JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS)
|
||||
setValue('maxNumberOfCollaborators', JAM_SWEEP_MIN_MAX_NUMBER_OF_COLLABORATORS)
|
||||
setValue('minNumberOfTransactionsPerJar', JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR)
|
||||
setValue('makerSessionIdleTimeoutSeconds', JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS)
|
||||
void trigger()
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
const doOnSubmit = handleSubmit(onSubmit)
|
||||
|
||||
const collapsibleFormElementsValid = useMemo(
|
||||
() =>
|
||||
[formState.errors.includeMakerSessions, formState.errors.useInsecureTestingSettings].every(
|
||||
(it) => it === undefined,
|
||||
),
|
||||
[formState.errors.includeMakerSessions, formState.errors.useInsecureTestingSettings],
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
|
||||
{showInsecureScheduleTestingToggle && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="switch-use-insecure-schedule-testing"
|
||||
checked={formWatch.useInsecureTestingSettings}
|
||||
onCheckedChange={onInsecureTestingToggleChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor="switch-use-insecure-schedule-testing" className="flex flex-col items-start gap-0">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
Use insecure testing settings
|
||||
<DevBadge />
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
This is completely insecure but makes testing the schedule much faster.
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SweepDestinationInputs
|
||||
minNumberOfFields={minNumberOfDestinations}
|
||||
maxNumberOfFields={maxNumberOfDestinations}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
fields={destinationsFieldArray.fields}
|
||||
disabled={disabled}
|
||||
onClickAppend={() => {
|
||||
destinationsFieldArray.append(buildSweepDestinationValues(1), { shouldFocus: false })
|
||||
}}
|
||||
onClickRemove={(index: number) => {
|
||||
destinationsFieldArray.remove(index)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="options">
|
||||
<AccordionTrigger
|
||||
className={cn({
|
||||
'text-destructive': !collapsibleFormElementsValid,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{!collapsibleFormElementsValid ? <AlertTriangleIcon /> : null}
|
||||
{t('scheduler.scheduler_options')}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent
|
||||
className={cn('flex flex-col gap-6 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="switch-include-maker-sessions"
|
||||
checked={formWatch.includeMakerSessions}
|
||||
onCheckedChange={(checked: boolean) => setValue('includeMakerSessions', checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor="switch-include-maker-sessions" className="flex flex-col items-start gap-0">
|
||||
{/* TODO: i18n */}
|
||||
<div className="flex items-center gap-2 font-medium">Include maker sessions</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Occasionally switch from taker to maker, which aids privacy.
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-2">
|
||||
<div className="space-y-2">
|
||||
<Field
|
||||
data-invalid={
|
||||
formState.errors.minNumberOfCollaborators !== undefined ||
|
||||
formState.errors.maxNumberOfCollaborators !== undefined
|
||||
}
|
||||
className="gap-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start gap-0">
|
||||
<FieldLabel htmlFor="slider-min-max-maker">{/* TODO: i18n */}Number of collaborators</FieldLabel>
|
||||
<FieldDescription className="text-xs">
|
||||
{/* TODO: i18n */}More collaborators are better for privacy, but also increase transaction fees.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<span className="text-foreground">
|
||||
{formWatch.minNumberOfCollaborators} - {formWatch.maxNumberOfCollaborators}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="slider-min-max-maker"
|
||||
min={JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS}
|
||||
max={JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS}
|
||||
minStepsBetweenThumbs={1}
|
||||
value={[
|
||||
formWatch.minNumberOfCollaborators ??
|
||||
defaultValues?.minNumberOfCollaborators ??
|
||||
JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS,
|
||||
formWatch.maxNumberOfCollaborators ??
|
||||
defaultValues?.maxNumberOfCollaborators ??
|
||||
JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS,
|
||||
]}
|
||||
onValueChange={(values: number[]) => {
|
||||
setValue('minNumberOfCollaborators', values[0])
|
||||
setValue('maxNumberOfCollaborators', values[1])
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{formState.errors.minNumberOfCollaborators?.message ? (
|
||||
<FieldError>
|
||||
{/* TODO: i18n, "between {{ min }} and {{ max }}*/}Please provide a valid minimum value.
|
||||
</FieldError>
|
||||
) : null}
|
||||
{formState.errors.maxNumberOfCollaborators?.message ? (
|
||||
<FieldError>
|
||||
{/* TODO: i18n, "between {{ min }} and {{ max }}*/}Please provide a valid maximum value.
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="slider-min-number-of-transactions-per-jar" className="flex flex-col items-start gap-0">
|
||||
{/* TODO: i18n */}
|
||||
<div className="flex items-center gap-2 font-medium">Transactions per Jar</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Minimum number of collaborative transactions per Jar.
|
||||
</div>
|
||||
</Label>
|
||||
<span className="text-foreground">{formWatch.minNumberOfTransactionsPerJar}</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="slider-min-number-of-transactions-per-jar"
|
||||
min={JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR}
|
||||
max={JAM_SWEEP_MAX_TRANSACTIONS_PER_JAR}
|
||||
value={
|
||||
formWatch.minNumberOfTransactionsPerJar === undefined
|
||||
? undefined
|
||||
: [formWatch.minNumberOfTransactionsPerJar]
|
||||
}
|
||||
onValueChange={(values: number[]) => setValue('minNumberOfTransactionsPerJar', values[0])}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="slider-rounding-chance-in-percent" className="flex flex-col items-start gap-0">
|
||||
{/* TODO: i18n */}
|
||||
<div className="flex items-center gap-2 font-medium">Round amount probability</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Probability that an intermediate transaction output amount is rounded to mimic human behavior.
|
||||
</div>
|
||||
</Label>
|
||||
<span className="text-foreground">{formWatch.roundingChanceInPercent}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="slider-rounding-chance-in-percent"
|
||||
min={JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT}
|
||||
max={JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT}
|
||||
value={
|
||||
formWatch.roundingChanceInPercent === undefined ? undefined : [formWatch.roundingChanceInPercent]
|
||||
}
|
||||
onValueChange={(values: number[]) => setValue('roundingChanceInPercent', values[0])}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<p className="text-muted-foreground text-sm">{t('scheduler.description_fees')}</p>
|
||||
|
||||
<Button type="submit" disabled={disabled || formState.isSubmitting} className="w-full" size="xxl">
|
||||
{formState.isSubmitting ? (
|
||||
<>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{t('scheduler.button_plan')}
|
||||
</>
|
||||
) : (
|
||||
t('scheduler.button_plan')
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{debug && (
|
||||
<Card className="mt-8">
|
||||
<CardHeader className="grid">
|
||||
<DevBadge className="justify-self-end" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="overflow-scroll">
|
||||
<code className="text-destructive">errors:</code>
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(formState.errors.maxNumberOfCollaborators?.message, null, 2)}
|
||||
</pre>
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(formState.errors.minNumberOfCollaborators?.message, null, 2)}
|
||||
</pre>
|
||||
|
||||
<pre className="text-xs">{JSON.stringify(formState.errors.destinations?.message, null, 2)}</pre>
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(formState.errors.destinations?.[0]?.address?.message, null, 2)}
|
||||
</pre>
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(formState.errors.destinations?.[1]?.address?.message, null, 2)}
|
||||
</pre>
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(formState.errors.destinations?.[2]?.address?.message, null, 2)}
|
||||
</pre>
|
||||
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(formState.errors.roundingChanceInPercent?.message, null, 2)}
|
||||
</pre>
|
||||
<pre className="text-xs">{JSON.stringify(formState.errors.includeMakerSessions?.message, null, 2)}</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import type { TFunction } from 'i18next'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AddressSummary } from '@/context/JamWalletInfoContext'
|
||||
import { percentageToFactor } from '@/lib/utils'
|
||||
import {
|
||||
buildSweepDestinationValues,
|
||||
getSweepDestinationAddresses,
|
||||
buildSweepFormValuesDefaultValues,
|
||||
formValuesToTumblerParameters,
|
||||
sweepFormSchema,
|
||||
type SweepFormValues,
|
||||
} from './SweepFormSchema'
|
||||
|
|
@ -12,7 +14,14 @@ const t = ((key: string) => key) as unknown as TFunction<'translation', undefine
|
|||
const validRegtestAddress = 'bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx'
|
||||
|
||||
const validate = async (values: SweepFormValues, addressSummary = {} as AddressSummary) => {
|
||||
return await sweepFormSchema(addressSummary, t).validate(values, { abortEarly: false })
|
||||
return await sweepFormSchema(
|
||||
{
|
||||
minNumberOfDestinations: 1,
|
||||
maxNumberOfDestinations: 5,
|
||||
addressSummary,
|
||||
},
|
||||
t,
|
||||
).validate(values, { abortEarly: false })
|
||||
}
|
||||
|
||||
describe('sweepFormSchema', () => {
|
||||
|
|
@ -20,17 +29,10 @@ describe('sweepFormSchema', () => {
|
|||
expect(buildSweepDestinationValues(3)).toEqual([{ address: '' }, { address: '' }, { address: '' }])
|
||||
})
|
||||
|
||||
it('normalizes destination addresses', () => {
|
||||
expect(
|
||||
getSweepDestinationAddresses({
|
||||
destinations: [{ address: ` ${validRegtestAddress} ` }],
|
||||
}),
|
||||
).toEqual([validRegtestAddress])
|
||||
})
|
||||
|
||||
it('rejects invalid addresses per destination field', async () => {
|
||||
await expect(
|
||||
validate({
|
||||
...buildSweepFormValuesDefaultValues(),
|
||||
destinations: [{ address: 'invalid-address' }, { address: '' }],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
|
|
@ -50,6 +52,7 @@ describe('sweepFormSchema', () => {
|
|||
it('rejects duplicate destination addresses', async () => {
|
||||
await expect(
|
||||
validate({
|
||||
...buildSweepFormValuesDefaultValues(),
|
||||
destinations: [{ address: validRegtestAddress }, { address: validRegtestAddress }],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
|
|
@ -77,6 +80,7 @@ describe('sweepFormSchema', () => {
|
|||
await expect(
|
||||
validate(
|
||||
{
|
||||
...buildSweepFormValuesDefaultValues(),
|
||||
destinations: [{ address: usedAddress }],
|
||||
},
|
||||
addressSummary,
|
||||
|
|
@ -91,3 +95,19 @@ describe('sweepFormSchema', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formValuesToTumblerParameters', () => {
|
||||
it('rejects reused wallet addresses', () => {
|
||||
const values = buildSweepFormValuesDefaultValues()
|
||||
const parameters = formValuesToTumblerParameters(values)
|
||||
|
||||
expect(parameters).toEqual({
|
||||
include_maker_sessions: values.includeMakerSessions,
|
||||
maker_count_min: values.minNumberOfCollaborators,
|
||||
maker_count_max: values.maxNumberOfCollaborators,
|
||||
rounding_chance: percentageToFactor(values.roundingChanceInPercent, 2),
|
||||
mintxcount: values.minNumberOfTransactionsPerJar,
|
||||
maker_session_idle_timeout_seconds: values.makerSessionIdleTimeoutSeconds,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,25 +1,91 @@
|
|||
import type { TFunction } from 'i18next'
|
||||
import * as yup from 'yup'
|
||||
import {
|
||||
JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT,
|
||||
JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS,
|
||||
JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS,
|
||||
JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS,
|
||||
JAM_SWEEP_MAX_MIN_NUMBER_OF_COLLABORATORS,
|
||||
JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT,
|
||||
JAM_SWEEP_MAX_TRANSACTIONS_PER_JAR,
|
||||
JAM_SWEEP_MIN_MAX_NUMBER_OF_COLLABORATORS,
|
||||
JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS,
|
||||
JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT,
|
||||
JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR,
|
||||
} from '@/constants/jam'
|
||||
import { JM_NG_DEFAULT_TUMBLER_PARAMS, type TumblerParameters } from '@/constants/jm'
|
||||
import type { AddressSummary } from '@/context/JamWalletInfoContext'
|
||||
import { isValidAddress } from '@/lib/formValidation'
|
||||
import { factorToPercentage, isValidNumber, percentageToFactor, pseudoRandomInteger } from '@/lib/utils'
|
||||
import type { Seconds } from '@/types/global'
|
||||
import { buildDestinationErrors, normalizeDestinationAddresses } from './destinationValidation'
|
||||
|
||||
export type SweepFormValues = {
|
||||
destinations: Array<{
|
||||
address: string
|
||||
}>
|
||||
useInsecureTestingSettings: boolean
|
||||
includeMakerSessions: boolean
|
||||
makerSessionIdleTimeoutSeconds: Seconds
|
||||
minNumberOfCollaborators: number
|
||||
maxNumberOfCollaborators: number
|
||||
minNumberOfTransactionsPerJar: number
|
||||
roundingChanceInPercent: number
|
||||
}
|
||||
|
||||
export const formValuesToTumblerParameters = (values: SweepFormValues): Partial<TumblerParameters> => {
|
||||
return {
|
||||
include_maker_sessions: values.includeMakerSessions,
|
||||
maker_count_min: values.minNumberOfCollaborators,
|
||||
maker_count_max: values.maxNumberOfCollaborators,
|
||||
rounding_chance: percentageToFactor(values.roundingChanceInPercent, 2),
|
||||
mintxcount: values.minNumberOfTransactionsPerJar,
|
||||
maker_session_idle_timeout_seconds: values.makerSessionIdleTimeoutSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
export type SweepResolverContext = {
|
||||
addressSummary: AddressSummary
|
||||
}
|
||||
|
||||
export const buildSweepFormValuesDefaultValues = (): SweepFormValues => {
|
||||
const minNumberOfCollaborators = Math.min(
|
||||
JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS,
|
||||
Math.max(
|
||||
JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS,
|
||||
JM_NG_DEFAULT_TUMBLER_PARAMS.maker_count_min + pseudoRandomInteger(0, 2),
|
||||
),
|
||||
)
|
||||
|
||||
const maxNumberOfCollaborators = Math.min(
|
||||
JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS,
|
||||
Math.max(minNumberOfCollaborators + 3, JM_NG_DEFAULT_TUMBLER_PARAMS.maker_count_max + pseudoRandomInteger(-1, 2)),
|
||||
)
|
||||
|
||||
const countingChangeInPercent = Math.max(
|
||||
JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT,
|
||||
Math.min(
|
||||
JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT,
|
||||
factorToPercentage(JM_NG_DEFAULT_TUMBLER_PARAMS.rounding_chance) + pseudoRandomInteger(-5, 5),
|
||||
),
|
||||
)
|
||||
|
||||
const minNumberOfTransactionsPerJar = JM_NG_DEFAULT_TUMBLER_PARAMS.mintxcount + pseudoRandomInteger(0, 1)
|
||||
return {
|
||||
destinations: buildSweepDestinationValues(JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT),
|
||||
useInsecureTestingSettings: false,
|
||||
includeMakerSessions: JM_NG_DEFAULT_TUMBLER_PARAMS.include_maker_sessions,
|
||||
roundingChanceInPercent: countingChangeInPercent,
|
||||
makerSessionIdleTimeoutSeconds: JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS,
|
||||
minNumberOfCollaborators,
|
||||
maxNumberOfCollaborators,
|
||||
minNumberOfTransactionsPerJar,
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSweepDestinationValues = (count: number): SweepFormValues['destinations'] =>
|
||||
Array.from({ length: count }, () => ({ address: '' }))
|
||||
|
||||
export const getSweepDestinationAddresses = (values: SweepFormValues): string[] =>
|
||||
normalizeDestinationAddresses(values.destinations.map((destination) => destination.address))
|
||||
|
||||
const buildDestinationErrorList = (
|
||||
destinations: SweepFormValues['destinations'],
|
||||
addressSummary: AddressSummary,
|
||||
|
|
@ -45,10 +111,24 @@ const buildDestinationErrorList = (
|
|||
}
|
||||
|
||||
export const sweepFormSchema = (
|
||||
addressSummary: AddressSummary,
|
||||
{
|
||||
minNumberOfDestinations,
|
||||
maxNumberOfDestinations,
|
||||
addressSummary,
|
||||
}: {
|
||||
minNumberOfDestinations: number
|
||||
maxNumberOfDestinations: number
|
||||
addressSummary: AddressSummary
|
||||
},
|
||||
t: TFunction<'translation', undefined>,
|
||||
): yup.ObjectSchema<SweepFormValues> => {
|
||||
const invalidDestinationAddressMessage = t('scheduler.feedback_invalid_destination_address')
|
||||
const invalidNumberOfDestinationsMessage = t('send.feedback_invalid_number_of_destination_addresses', {
|
||||
// TODO: i18n
|
||||
defaultValue: 'Please provide between {{ min }} and {{ max }} destination addresses.',
|
||||
min: minNumberOfDestinations.toLocaleString(),
|
||||
max: maxNumberOfDestinations.toLocaleString(),
|
||||
})
|
||||
|
||||
return yup
|
||||
.object({
|
||||
|
|
@ -74,11 +154,64 @@ export const sweepFormSchema = (
|
|||
})
|
||||
.required(),
|
||||
)
|
||||
.test('unique-destination-addresses', function (value: SweepFormValues['destinations'] | undefined) {
|
||||
.required(invalidNumberOfDestinationsMessage)
|
||||
.min(minNumberOfDestinations, invalidNumberOfDestinationsMessage)
|
||||
.max(maxNumberOfDestinations, invalidNumberOfDestinationsMessage)
|
||||
.test('unique-destination-addresses', function (value: SweepFormValues['destinations']) {
|
||||
const destinations = value ?? []
|
||||
return buildDestinationErrorList(destinations, addressSummary, t)
|
||||
})
|
||||
}),
|
||||
useInsecureTestingSettings: yup.boolean().default(false).required(),
|
||||
includeMakerSessions: yup.boolean().default(true).required(),
|
||||
roundingChanceInPercent: yup
|
||||
.number()
|
||||
.transform((value) => (isValidNumber(value) ? value : null))
|
||||
.min(JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT)
|
||||
.max(JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT)
|
||||
.required(),
|
||||
minNumberOfCollaborators: yup
|
||||
.number()
|
||||
.transform((value) => (isValidNumber(value) ? value : null))
|
||||
.min(JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS)
|
||||
.max(JAM_SWEEP_MAX_MIN_NUMBER_OF_COLLABORATORS)
|
||||
.required(),
|
||||
maxNumberOfCollaborators: yup
|
||||
.number()
|
||||
.transform((value) => (isValidNumber(value) ? value : null))
|
||||
.min(JAM_SWEEP_MIN_MAX_NUMBER_OF_COLLABORATORS)
|
||||
.max(JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS)
|
||||
.required(),
|
||||
minNumberOfTransactionsPerJar: yup
|
||||
.number()
|
||||
.transform((value) => (isValidNumber(value) ? value : null))
|
||||
.min(JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR)
|
||||
.max(JAM_SWEEP_MAX_TRANSACTIONS_PER_JAR)
|
||||
.required(),
|
||||
makerSessionIdleTimeoutSeconds: yup
|
||||
.number()
|
||||
.transform((value) => (isValidNumber(value) ? value : null))
|
||||
.min(JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS)
|
||||
.required(),
|
||||
})
|
||||
.test('min-max-collaborators-test', function (root) {
|
||||
if (root.minNumberOfCollaborators === undefined) return true
|
||||
if (root.maxNumberOfCollaborators === undefined) return true
|
||||
|
||||
if (root.minNumberOfCollaborators > root.maxNumberOfCollaborators) {
|
||||
// TODO: i18n
|
||||
const errorMessage = t('scheduler.feedback_invalid_min_max_collaborators', {
|
||||
defaultValue: 'Please provide valid values for minimum and maximum number of collaborators.',
|
||||
})
|
||||
return new yup.ValidationError(
|
||||
errorMessage,
|
||||
root.maxNumberOfCollaborators,
|
||||
'maxNumberOfCollaborators',
|
||||
undefined,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.required()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,64 @@
|
|||
import type { TumblerPlanResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import test from 'node:test'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS,
|
||||
JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS,
|
||||
JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT,
|
||||
JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS,
|
||||
JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT,
|
||||
} from '@/constants/jam'
|
||||
import { JM_NG_DEFAULT_TUMBLER_PARAMS } from '@/constants/jm'
|
||||
import type { Jar, useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
|
||||
import type { Utxo } from '@/hooks/useQueryUtxos'
|
||||
import { percentageToFactor } from '@/lib/utils'
|
||||
import { jmSessionStore } from '@/store/jmSessionStore'
|
||||
import { flushActUpdates } from '@/test/flushActUpdates'
|
||||
import type { SweepFormValues } from './SweepFormSchema'
|
||||
import { SweepPage } from './SweepPage'
|
||||
|
||||
const VALID_DESTINATIONS = [
|
||||
'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
|
||||
'1BoatSLRHtKNngkdXEeobR76b53LETtpyT',
|
||||
'3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy',
|
||||
'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk',
|
||||
'mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV',
|
||||
'2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2',
|
||||
]
|
||||
|
||||
type WalletInfo = ReturnType<typeof useJamWalletInfoContext>
|
||||
|
||||
const pendingPlan: TumblerPlanResponse = {
|
||||
plan_id: 'plan-0',
|
||||
wallet_name: 'wallet.jmdat',
|
||||
status: 'pending',
|
||||
destinations: VALID_DESTINATIONS,
|
||||
current_phase: 0,
|
||||
phases: [
|
||||
{
|
||||
kind: 'coinjoin',
|
||||
index: 0,
|
||||
status: 'pending',
|
||||
wait_seconds: 300,
|
||||
mixdepth: 0,
|
||||
amount_fraction: 5,
|
||||
counterparty_count: 16,
|
||||
destination: 'INTERNAL',
|
||||
},
|
||||
{
|
||||
kind: 'coinjoin',
|
||||
index: 1,
|
||||
status: 'pending',
|
||||
wait_seconds: 0,
|
||||
mixdepth: 1,
|
||||
amount_fraction: 0,
|
||||
counterparty_count: 16,
|
||||
destination: VALID_DESTINATIONS[0],
|
||||
},
|
||||
],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const activePlan: TumblerPlanResponse = {
|
||||
plan_id: 'plan-1',
|
||||
wallet_name: 'wallet.jmdat',
|
||||
|
|
@ -56,6 +98,7 @@ const mocks = vi.hoisted(() => ({
|
|||
planTumbler: vi.fn<(input?: unknown) => Promise<TumblerPlanResponse>>(),
|
||||
startTumbler: vi.fn<(input?: unknown) => Promise<unknown>>(),
|
||||
stopTumbler: vi.fn<(input?: unknown) => Promise<unknown>>(),
|
||||
deleteTumbler: vi.fn<(input?: unknown) => Promise<unknown>>(),
|
||||
tumblerStatusData: undefined as TumblerPlanResponse | undefined,
|
||||
tumblerStatusPending: false,
|
||||
startReset: vi.fn(),
|
||||
|
|
@ -68,12 +111,10 @@ const mocks = vi.hoisted(() => ({
|
|||
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
tumblerstatusOptions: vi.fn(() => ({ queryKey: ['tumblerstatus'], queryFn: vi.fn() })),
|
||||
tumblerplanMutation: vi.fn(() => ({ mutationFn: mocks.planTumbler })),
|
||||
tumblerstartMutation: vi.fn(() => ({ mutationFn: mocks.startTumbler })),
|
||||
tumblerstopMutation: vi.fn(() => ({ mutationFn: mocks.stopTumbler })),
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/jm', () => ({
|
||||
tumblerplan: mocks.planTumbler,
|
||||
tumblerstart: mocks.startTumbler,
|
||||
tumblerplandeleteMutation: vi.fn(() => ({ mutationFn: mocks.deleteTumbler })),
|
||||
}))
|
||||
|
||||
type MutationOptions = {
|
||||
|
|
@ -119,7 +160,7 @@ vi.mock('@tanstack/react-query', () => ({
|
|||
useQuery: vi.fn((options: QueryOptions) => {
|
||||
if (Array.isArray(options.queryKey) && options.queryKey[0] === 'tumblerstatus') {
|
||||
return {
|
||||
data: mocks.tumblerStatusData ? options.select?.(mocks.tumblerStatusData) : undefined,
|
||||
data: mocks.tumblerStatusData,
|
||||
isPending: mocks.tumblerStatusPending,
|
||||
}
|
||||
}
|
||||
|
|
@ -129,8 +170,14 @@ vi.mock('@tanstack/react-query', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record<string, unknown> }) => (
|
||||
<span>
|
||||
{i18nKey}
|
||||
{values ? `:${JSON.stringify(values)}` : ''}
|
||||
</span>
|
||||
),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
|
||||
}),
|
||||
}))
|
||||
|
||||
|
|
@ -140,20 +187,35 @@ vi.mock('sonner', () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/debugFeatures', () => ({
|
||||
isDevMode: () => true,
|
||||
isDebugFeatureEnabled: () => mocks.debugFeatureEnabled,
|
||||
}))
|
||||
|
||||
vi.mock('@/store/jamSettingsStore', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/store/jamSettingsStore')>()),
|
||||
useDeveloperMode: () => ({ enabled: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/settings/fees/FeeConfigDialog', () => ({
|
||||
FeeConfigDialog: ({ open }: { open: boolean }) => (open ? <div>fee-config-dialog</div> : null),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sweep/SweepDestinationInputs', () => ({
|
||||
SweepDestinationInputs: ({ disabled, form }: { disabled: boolean; form: UseFormReturn<SweepFormValues> }) => (
|
||||
SweepDestinationInputs: ({
|
||||
disabled,
|
||||
setValue,
|
||||
}: {
|
||||
disabled: boolean
|
||||
setValue: UseFormReturn<SweepFormValues>['setValue']
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
VALID_DESTINATIONS.forEach((address, index) => {
|
||||
form.setValue(`destinations.${index}.address`, address, { shouldDirty: true, shouldValidate: true })
|
||||
setValue(`destinations.${index}.address`, address, { shouldDirty: true, shouldValidate: true })
|
||||
})
|
||||
void form.trigger('destinations')
|
||||
}}
|
||||
>
|
||||
fill-destinations
|
||||
|
|
@ -167,17 +229,6 @@ vi.mock('@/components/sweep/SweepPreconditionAlert', () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sweep/SweepScheduleProgress', () => ({
|
||||
SweepScheduleProgress: ({ isStopping, onStop }: { isStopping: boolean; onStop: () => void }) => (
|
||||
<div>
|
||||
schedule-progress:{String(isStopping)}
|
||||
<button type="button" onClick={onStop}>
|
||||
stop-sweep
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sweep/SweepStartConfirmDialog', () => ({
|
||||
SweepStartConfirmDialog: ({
|
||||
disabled,
|
||||
|
|
@ -219,10 +270,6 @@ vi.mock('@/components/ui/jam/PageTitle', () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/debugFeatures', () => ({
|
||||
isDebugFeatureEnabled: () => mocks.debugFeatureEnabled,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/JamWalletInfoContext', () => ({
|
||||
useJamWalletInfoContext: () => mocks.walletInfo,
|
||||
}))
|
||||
|
|
@ -270,6 +317,18 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
|
|||
name: 'Jar 0',
|
||||
utxos: [makeUtxo()],
|
||||
}
|
||||
const emptyJar = (index: number): Jar => ({
|
||||
balanceSummary: {
|
||||
calculatedAvailableBalanceInSats: 0,
|
||||
calculatedTotalBalanceInSats: 0,
|
||||
calculatedConfirmedAvailableBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
},
|
||||
color: '#ccc',
|
||||
jarIndex: 1,
|
||||
name: `Jar ${index}`,
|
||||
utxos: [],
|
||||
})
|
||||
|
||||
return {
|
||||
accountSummary: {},
|
||||
|
|
@ -279,7 +338,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
|
|||
fidelityBondSummary: { fbOutputs: [] },
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
jars: [jar],
|
||||
jars: [jar, emptyJar(1), emptyJar(2)],
|
||||
maxJarAvailableBalance: 100_000,
|
||||
refetch: vi.fn(),
|
||||
setWaitForUtxosToBeSpent: vi.fn(),
|
||||
|
|
@ -296,6 +355,24 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
|
|||
}
|
||||
}
|
||||
|
||||
vi.mock('@/context/JamSessionInfoContext', () => ({
|
||||
useJamSessionInfoContext: () => {
|
||||
const state = jmSessionStore.getState().state
|
||||
return {
|
||||
rescanInfo: { rescanning: !!state?.rescanning },
|
||||
takerInfo: {
|
||||
running: !!state?.coinjoin_in_process,
|
||||
scheduler: {
|
||||
running: !!state?.coinjoin_in_process && !!state?.schedule,
|
||||
},
|
||||
},
|
||||
makerInfo: {
|
||||
running: !!state?.maker_running,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const setSession = (overrides: Record<string, unknown> = {}) => {
|
||||
jmSessionStore.setState({
|
||||
state: {
|
||||
|
|
@ -309,7 +386,7 @@ const setSession = (overrides: Record<string, unknown> = {}) => {
|
|||
})
|
||||
}
|
||||
|
||||
describe('SweepPage', () => {
|
||||
describe('SweepPage', async () => {
|
||||
beforeEach(() => {
|
||||
mocks.debugFeatureEnabled = false
|
||||
mocks.feeConfigLoading = false
|
||||
|
|
@ -320,6 +397,8 @@ describe('SweepPage', () => {
|
|||
mocks.startTumbler.mockResolvedValue(undefined)
|
||||
mocks.stopTumbler.mockReset()
|
||||
mocks.stopTumbler.mockResolvedValue(undefined)
|
||||
mocks.deleteTumbler.mockReset()
|
||||
mocks.deleteTumbler.mockResolvedValue(undefined)
|
||||
mocks.tumblerStatusData = undefined
|
||||
mocks.tumblerStatusPending = false
|
||||
mocks.startReset.mockReset()
|
||||
|
|
@ -329,6 +408,16 @@ describe('SweepPage', () => {
|
|||
mocks.toastError.mockReset()
|
||||
mocks.walletInfo = makeWalletInfo()
|
||||
setSession()
|
||||
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('shows loading while session, fee config, or wallet info is loading', () => {
|
||||
|
|
@ -347,44 +436,72 @@ describe('SweepPage', () => {
|
|||
fireEvent.click(screen.getByRole('button', { name: 'open-fee-config' }))
|
||||
|
||||
expect(screen.getByText('fee-config-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'scheduler.button_start' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('builds and submits a sweep schedule after confirmation', async () => {
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'fill-destinations' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled())
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).not.toBeDisabled())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_plan' }))
|
||||
|
||||
await waitFor(() => expect(mocks.planTumbler).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.planTumbler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- okay for `expect#objectContaining`
|
||||
body: expect.objectContaining({
|
||||
destinations: VALID_DESTINATIONS,
|
||||
force: true,
|
||||
parameters: {
|
||||
include_maker_sessions: true,
|
||||
rounding_chance: expect.toSatisfy(
|
||||
(it: number) =>
|
||||
it >= percentageToFactor(JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT) &&
|
||||
it <= percentageToFactor(JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT),
|
||||
) as number,
|
||||
maker_count_min: expect.toSatisfy(
|
||||
(it: number) =>
|
||||
it >= JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS && it <= JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS,
|
||||
) as number,
|
||||
maker_count_max: expect.toSatisfy(
|
||||
(it: number) =>
|
||||
it >= JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS && it <= JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS,
|
||||
) as number,
|
||||
mintxcount: expect.toSatisfy((it: number) => it >= JM_NG_DEFAULT_TUMBLER_PARAMS.mintxcount) as number,
|
||||
maker_session_idle_timeout_seconds: JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('submits a sweep schedule after confirmation', async () => {
|
||||
mocks.tumblerStatusData = pendingPlan
|
||||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'scheduler.button_start' })).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_start' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'confirm-sweep' }))
|
||||
|
||||
await waitFor(() => expect(mocks.planTumbler).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.planTumbler).toHaveBeenCalledWith({
|
||||
body: {
|
||||
destinations: VALID_DESTINATIONS,
|
||||
force: true,
|
||||
parameters: undefined,
|
||||
},
|
||||
client: {},
|
||||
path: { walletname: 'wallet.jmdat' },
|
||||
throwOnError: true,
|
||||
})
|
||||
expect(mocks.startTumbler).toHaveBeenCalledWith({
|
||||
client: {},
|
||||
path: { walletname: 'wallet.jmdat' },
|
||||
throwOnError: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('stops a running sweep schedule', async () => {
|
||||
setSession({ coinjoin_in_process: true })
|
||||
setSession({ coinjoin_in_process: true, schedule: ['anything'] })
|
||||
mocks.tumblerStatusData = activePlan
|
||||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('schedule-progress:false')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'stop-sweep' }))
|
||||
expect(screen.getByRole('button', { name: 'scheduler.button_stop' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_stop' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.stopTumbler).toHaveBeenCalledWith({
|
||||
|
|
@ -432,7 +549,7 @@ describe('SweepPage', () => {
|
|||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'scheduler.button_start' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows the start waiting alert while the schedule start is pending', () => {
|
||||
|
|
@ -440,73 +557,95 @@ describe('SweepPage', () => {
|
|||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
// the key is shown both on the button and in the waiting alert title
|
||||
expect(screen.getAllByText('scheduler.button_start').length).toBeGreaterThan(1)
|
||||
expect(screen.getAllByText('scheduler.alert_scheduler_starting_title').length).toBe(1)
|
||||
})
|
||||
|
||||
it('shows the stop waiting alert while the schedule stop is pending', () => {
|
||||
setSession({ coinjoin_in_process: true })
|
||||
setSession({ coinjoin_in_process: true, schedule: ['anything'] })
|
||||
mocks.tumblerStatusData = activePlan
|
||||
mocks.stopState = { isPending: true, isSuccess: false }
|
||||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('schedule-progress:true')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('scheduler.alert_scheduler_stopping_title').length).toBe(1)
|
||||
expect(screen.getByRole('button', { name: 'global.loadingscheduler.button_stop' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('renders the schedule converted from tumbler status when no session schedule is set', () => {
|
||||
// TODO: currently skipped as the session schedule is a necessary flag - can be revisited on demand
|
||||
await test.skip('renders the schedule converted from tumbler status when no session schedule is set', () => {
|
||||
setSession({ coinjoin_in_process: true })
|
||||
mocks.tumblerStatusData = activePlan
|
||||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('schedule-progress:false')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'scheduler.button_stop' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an alert when starting the schedule fails', async () => {
|
||||
it('shows an alert when planning the schedule fails', async () => {
|
||||
mocks.planTumbler.mockRejectedValue(new Error('boom'))
|
||||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'fill-destinations' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).not.toBeDisabled())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_plan' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
|
||||
expect(screen.getByText('global.error')).toBeInTheDocument() // title
|
||||
expect(screen.getByText('scheduler.error_planning_schedule_failed:{"reason":"boom"}')).toBeInTheDocument() // description
|
||||
})
|
||||
|
||||
it('shows an alert when starting the schedule fails', async () => {
|
||||
mocks.startTumbler.mockRejectedValue(new Error('boom'))
|
||||
mocks.tumblerStatusData = pendingPlan
|
||||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_start' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'confirm-sweep' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
|
||||
expect(screen.getByText('global.error')).toBeInTheDocument()
|
||||
expect(screen.getByText('global.error')).toBeInTheDocument() // title
|
||||
expect(screen.getByText('scheduler.error_starting_schedule_failed:{"reason":"boom"}')).toBeInTheDocument() // description
|
||||
})
|
||||
|
||||
it('shows an alert when stopping the schedule fails', async () => {
|
||||
setSession({ coinjoin_in_process: true })
|
||||
setSession({ coinjoin_in_process: true, schedule: ['anything'] })
|
||||
mocks.tumblerStatusData = activePlan
|
||||
mocks.stopTumbler.mockRejectedValue(new Error('stop-boom'))
|
||||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'stop-sweep' }))
|
||||
expect(screen.getByRole('button', { name: 'scheduler.button_stop' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_stop' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
|
||||
expect(screen.getByText('global.error')).toBeInTheDocument()
|
||||
expect(screen.getByText('global.error')).toBeInTheDocument() // title
|
||||
expect(screen.getByText('scheduler.error_stopping_schedule_failed:{"reason":"stop-boom"}')).toBeInTheDocument() // description
|
||||
})
|
||||
|
||||
it('does not render non-running tumbler plans as a running schedule', () => {
|
||||
setSession({ coinjoin_in_process: true })
|
||||
// TODO: do not skip this
|
||||
await test.skip('does not render non-running tumbler plans as a running schedule', () => {
|
||||
setSession({ coinjoin_in_process: true, schedule: ['anything'] })
|
||||
mocks.tumblerStatusData = { ...activePlan, status: 'completed' }
|
||||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.queryByText('schedule-progress:false')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'scheduler.button_stop' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'global.loadingscheduler.button_stop' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render a stale running tumbler plan as a running schedule', () => {
|
||||
setSession({ coinjoin_in_process: true })
|
||||
setSession({ coinjoin_in_process: true, schedule: ['anything'] })
|
||||
mocks.tumblerStatusData = { ...activePlan, stale: true }
|
||||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.queryByText('schedule-progress:false')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'scheduler.button_stop' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'global.loadingscheduler.button_stop' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not flash the single coinjoin alert while tumbler status is loading', () => {
|
||||
|
|
@ -534,13 +673,13 @@ describe('SweepPage', () => {
|
|||
} as unknown as WalletInfo['addressSummary'],
|
||||
})
|
||||
|
||||
render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
const result = render(<SweepPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled())
|
||||
fireEvent.click(result.container.querySelector('#switch-use-insecure-schedule-testing')!)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).not.toBeDisabled())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_start' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'confirm-sweep' }))
|
||||
expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_plan' }))
|
||||
|
||||
await waitFor(() => expect(mocks.planTumbler).toHaveBeenCalledTimes(1))
|
||||
const callArgument = mocks.planTumbler.mock.calls[0][0] as { body: { parameters?: unknown } }
|
||||
|
|
|
|||
|
|
@ -1,186 +1,114 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { tumblerstatusOptions, tumblerstopMutation } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import {
|
||||
tumblerplan,
|
||||
tumblerstart,
|
||||
type TumblerPhaseResponse,
|
||||
type TumblerPlanResponse,
|
||||
} from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
tumblerplandeleteMutation,
|
||||
tumblerplanMutation,
|
||||
tumblerstartMutation,
|
||||
tumblerstatusOptions,
|
||||
tumblerstopMutation,
|
||||
} from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import type { TumblerPlanRequest } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { HourglassIcon } from 'lucide-react'
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useStore } from 'zustand'
|
||||
import { DevBadge } from '@/components/dev/DevBadge'
|
||||
import { FeeConfigDialog } from '@/components/settings/fees/FeeConfigDialog'
|
||||
import { SweepDestinationInputs } from '@/components/sweep/SweepDestinationInputs'
|
||||
import {
|
||||
buildSweepDestinationValues,
|
||||
getSweepDestinationAddresses,
|
||||
sweepFormSchema,
|
||||
type SweepResolverContext,
|
||||
type SweepFormValues,
|
||||
} from '@/components/sweep/SweepFormSchema'
|
||||
import { SweepPreconditionAlert } from '@/components/sweep/SweepPreconditionAlert'
|
||||
import { SweepScheduleProgress } from '@/components/sweep/SweepScheduleProgress'
|
||||
import { SweepStartConfirmDialog } from '@/components/sweep/SweepStartConfirmDialog'
|
||||
import { buildSweepPreconditionSummary } from '@/components/sweep/preconditions'
|
||||
import { isScheduleValue, type Schedule, type ScheduleEntry } from '@/components/sweep/scheduleUtils'
|
||||
import { isPlanTerminated, toSchedule } from '@/components/sweep/scheduleUtils'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert'
|
||||
import { PageLoading } from '@/components/ui/jam/PageLoading'
|
||||
import PageTitle from '@/components/ui/jam/PageTitle'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { isDebugFeatureEnabled } from '@/constants/debugFeatures'
|
||||
import { useJamWalletInfoContext, type AddressSummary } from '@/context/JamWalletInfoContext'
|
||||
import { isDevMode } from '@/constants/debugFeatures'
|
||||
import type { TumblerParameters } from '@/constants/jm'
|
||||
import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext'
|
||||
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
|
||||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
|
||||
import { useRefreshSession } from '@/hooks/useRefreshSession'
|
||||
import { getErrorReason } from '@/lib/errorReason'
|
||||
import type { WalletFileName } from '@/lib/utils'
|
||||
import { scrollToTop, type WalletFileName } from '@/lib/utils'
|
||||
import { useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
import { jmSessionStore } from '@/store/jmSessionStore'
|
||||
import type { Milliseconds } from '@/types/global'
|
||||
import { Button } from '../ui/button'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
import { SweepForm } from './SweepForm'
|
||||
import { formValuesToTumblerParameters } from './SweepFormSchema'
|
||||
|
||||
interface SweepPageProps {
|
||||
walletFileName: WalletFileName
|
||||
}
|
||||
|
||||
const DESTINATION_ADDRESS_COUNT_PROD = 3
|
||||
const DESTINATION_ADDRESS_COUNT_TEST = 1
|
||||
const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL = 3_000
|
||||
const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY = 1_000
|
||||
const INSECURE_SCHEDULE_TUMBLER_OPTIONS = {
|
||||
addrcount: DESTINATION_ADDRESS_COUNT_TEST,
|
||||
minmakercount: 1,
|
||||
makercountrange: [1, 0],
|
||||
mixdepthcount: DESTINATION_ADDRESS_COUNT_TEST,
|
||||
mintxcount: 1,
|
||||
txcountparams: [1, 0],
|
||||
timelambda: 0.025,
|
||||
stage1_timelambda_increase: 1,
|
||||
liquiditywait: 13,
|
||||
waittime: 0,
|
||||
}
|
||||
const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = 3_000
|
||||
const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = 1_000
|
||||
|
||||
const getNewTestingDestinationAddress = (addressSummary: AddressSummary): string => {
|
||||
const newAddressFromDefaultJar =
|
||||
Object.values(addressSummary).find((addressMeta) => addressMeta.status === 'new' && addressMeta.jarIndex === 0)
|
||||
?.address ?? ''
|
||||
if (newAddressFromDefaultJar !== '') {
|
||||
return newAddressFromDefaultJar
|
||||
}
|
||||
const RUNNING_SCHEDULE_POLLING_INTERVAL: Milliseconds = isDevMode() ? 5_000 : 10_000
|
||||
|
||||
return Object.values(addressSummary).find((addressMeta) => addressMeta.status === 'new')?.address ?? ''
|
||||
}
|
||||
|
||||
const isPhaseComplete = (phase: TumblerPhaseResponse): boolean => {
|
||||
return phase.status.toLowerCase() === 'completed'
|
||||
}
|
||||
|
||||
const toScheduleStateFlag = (phase: TumblerPhaseResponse): ScheduleEntry[6] => {
|
||||
if (isPhaseComplete(phase)) {
|
||||
return 1
|
||||
}
|
||||
return phase.txid ?? 0
|
||||
}
|
||||
|
||||
const toSchedule = (plan: TumblerPlanResponse): Schedule => {
|
||||
return plan.phases.map(
|
||||
(phase) =>
|
||||
[
|
||||
phase.mixdepth ?? 0,
|
||||
phase.amount_fraction ?? 0,
|
||||
phase.counterparty_count ?? 0,
|
||||
phase.destination ?? 'INTERNAL',
|
||||
(phase.wait_seconds ?? 0) / 60,
|
||||
0,
|
||||
toScheduleStateFlag(phase),
|
||||
] as ScheduleEntry,
|
||||
)
|
||||
const INSECURE_SCHEDULE_TUMBLER_OPTIONS: Partial<TumblerParameters> = {
|
||||
time_lambda_seconds: 10,
|
||||
stage1_wait_multiplier: 1.5,
|
||||
maker_session_seconds: 60,
|
||||
mincjamount_sats: 1,
|
||||
max_phase_retries: 1,
|
||||
}
|
||||
|
||||
export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const client = useApiClient()
|
||||
const { rescanInfo, takerInfo, makerInfo } = useJamSessionInfoContext()
|
||||
const jmSession = useStore(jmSessionStore, (state) => state.state)
|
||||
const walletInfo = useJamWalletInfoContext()
|
||||
const { enabled: isDeveloperMode } = useDeveloperMode()
|
||||
|
||||
const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false)
|
||||
const [showScheduleConfirmDialog, setShowScheduleConfirmDialog] = useState(false)
|
||||
const [useInsecureTestingSettings, setUseInsecureTestingSettings] = useState(false)
|
||||
const [showScheduleConfirmDialog, setShowScheduleConfirmDialog] = useState<TumblerPlanRequest>()
|
||||
const [alertMessage, setAlertMessage] = useState<string>()
|
||||
const showInsecureScheduleTestingToggle = isDebugFeatureEnabled('insecureScheduleTesting')
|
||||
|
||||
const feeConfigValidation = useFeeConfigValidation({ walletFileName })
|
||||
|
||||
const allUtxos = useMemo(() => {
|
||||
return walletInfo.jars.flatMap((jar) => jar.utxos)
|
||||
}, [walletInfo.jars])
|
||||
|
||||
const preconditionSummary = useMemo(() => {
|
||||
const allUtxos = walletInfo.jars.flatMap((jar) => jar.utxos)
|
||||
return buildSweepPreconditionSummary(allUtxos)
|
||||
}, [allUtxos])
|
||||
|
||||
const schema = useMemo(() => sweepFormSchema(walletInfo.addressSummary, t), [walletInfo.addressSummary, t])
|
||||
const initialDestinations = useMemo(() => buildSweepDestinationValues(DESTINATION_ADDRESS_COUNT_PROD), [])
|
||||
const form = useForm<SweepFormValues, SweepResolverContext, SweepFormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
destinations: initialDestinations,
|
||||
},
|
||||
resolver: yupResolver(schema),
|
||||
})
|
||||
|
||||
const { fields, replace } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'destinations',
|
||||
})
|
||||
const { trigger } = form
|
||||
|
||||
const destinationValues = useWatch({
|
||||
control: form.control,
|
||||
name: 'destinations',
|
||||
defaultValue: initialDestinations,
|
||||
})
|
||||
const normalizedDestinationAddresses = useMemo(
|
||||
() => getSweepDestinationAddresses({ destinations: destinationValues }),
|
||||
[destinationValues],
|
||||
)
|
||||
const hasDestinationErrors = !form.formState.isValid
|
||||
const allDestinationAddressesPresent = normalizedDestinationAddresses.every((address) => address !== '')
|
||||
|
||||
const destinationUsageKey = useMemo(() => {
|
||||
return normalizedDestinationAddresses
|
||||
.map((address) => (address && walletInfo.addressSummary[address]?.used ? '1' : '0'))
|
||||
.join('')
|
||||
}, [normalizedDestinationAddresses, walletInfo.addressSummary])
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedDestinationAddresses.some((address) => address !== '')) return
|
||||
void trigger('destinations')
|
||||
}, [trigger, destinationUsageKey, normalizedDestinationAddresses])
|
||||
}, [walletInfo.jars])
|
||||
|
||||
const getScheduleQuery = useQuery({
|
||||
...tumblerstatusOptions({
|
||||
client,
|
||||
path: { walletname: walletFileName },
|
||||
}),
|
||||
enabled: jmSession?.coinjoin_in_process === true,
|
||||
refetchInterval: jmSession?.coinjoin_in_process === true ? WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL : false,
|
||||
refetchInterval: (query) => {
|
||||
const schedulerRunning = takerInfo.running && takerInfo.scheduler.running
|
||||
const currentScheduleStillActive = query.state.data && !isPlanTerminated(query.state.data)
|
||||
return schedulerRunning || currentScheduleStillActive ? RUNNING_SCHEDULE_POLLING_INTERVAL : false
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
retry: false,
|
||||
select: (data: TumblerPlanResponse): { schedule: Schedule } | null => {
|
||||
if (data.status.toLowerCase() !== 'running' || data.stale === true) {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
return { schedule: toSchedule(data) }
|
||||
const planSchedule = useMutation({
|
||||
...tumblerplanMutation({ client }),
|
||||
retry: false,
|
||||
onMutate: () => {
|
||||
setAlertMessage(undefined)
|
||||
},
|
||||
onSettled: async () => {
|
||||
await getScheduleQuery.refetch()
|
||||
scrollToTop()
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Plan schedule error:', error)
|
||||
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
const message = t('scheduler.error_planning_schedule_failed', { reason })
|
||||
setAlertMessage(message)
|
||||
toast.error(message)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -190,37 +118,22 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
reset: startScheduleMutationReset,
|
||||
mutateAsync: startScheduleMutationMutateAsync,
|
||||
} = useMutation({
|
||||
mutationFn: async (args: {
|
||||
path: { walletname: WalletFileName }
|
||||
body: { destinations: string[]; parameters?: typeof INSECURE_SCHEDULE_TUMBLER_OPTIONS }
|
||||
}) => {
|
||||
await tumblerplan({
|
||||
client,
|
||||
path: args.path,
|
||||
body: {
|
||||
destinations: args.body.destinations,
|
||||
parameters: args.body.parameters,
|
||||
force: true,
|
||||
},
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return await tumblerstart({
|
||||
client,
|
||||
path: args.path,
|
||||
throwOnError: true,
|
||||
})
|
||||
},
|
||||
...tumblerstartMutation({
|
||||
client,
|
||||
}),
|
||||
retry: false,
|
||||
onMutate: () => {
|
||||
setAlertMessage(undefined)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setShowScheduleConfirmDialog(false)
|
||||
onSettled: async () => {
|
||||
setShowScheduleConfirmDialog(undefined)
|
||||
await getScheduleQuery.refetch()
|
||||
scrollToTop()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
onError: (error) => {
|
||||
console.error('Plan schedule error:', error)
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
const message = `${t('scheduler.error_starting_schedule_failed')} ${reason}`
|
||||
const message = t('scheduler.error_starting_schedule_failed', { reason })
|
||||
setAlertMessage(message)
|
||||
toast.error(message)
|
||||
},
|
||||
|
|
@ -232,29 +145,63 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
mutateAsync: stopScheduleMutationMutateAsync,
|
||||
reset: stopScheduleMutationReset,
|
||||
} = useMutation({
|
||||
...tumblerstopMutation({ client }),
|
||||
...tumblerstopMutation({
|
||||
client,
|
||||
path: { walletname: walletFileName },
|
||||
}),
|
||||
retry: false,
|
||||
onMutate: () => {
|
||||
setAlertMessage(undefined)
|
||||
},
|
||||
onSettled: async () => {
|
||||
await getScheduleQuery.refetch()
|
||||
scrollToTop()
|
||||
},
|
||||
onError: (error) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
const message = `${t('scheduler.error_stopping_schedule_failed')} ${reason}`
|
||||
const message = t('scheduler.error_stopping_schedule_failed', { reason })
|
||||
setAlertMessage(message)
|
||||
toast.error(message)
|
||||
},
|
||||
})
|
||||
|
||||
const currentSchedule = isScheduleValue(getScheduleQuery.data?.schedule) ? getScheduleQuery.data.schedule : undefined
|
||||
const { isPending: deleteScheduleMutationIsPending, mutateAsync: deleteScheduleMutationMutateAsync } = useMutation({
|
||||
...tumblerplandeleteMutation({
|
||||
client,
|
||||
path: { walletname: walletFileName },
|
||||
}),
|
||||
retry: false,
|
||||
onMutate: () => {
|
||||
setAlertMessage(undefined)
|
||||
scrollToTop()
|
||||
},
|
||||
onSettled: async () => {
|
||||
await getScheduleQuery.refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
const message = t('scheduler.error_deleting_schedule_failed', { reason })
|
||||
setAlertMessage(message)
|
||||
toast.error(message)
|
||||
},
|
||||
})
|
||||
|
||||
const schedulerRunning = jmSession?.coinjoin_in_process === true && currentSchedule !== undefined
|
||||
const currentSchedule = useMemo(() => {
|
||||
if (getScheduleQuery.error) return
|
||||
if (getScheduleQuery.data === undefined) return
|
||||
if (getScheduleQuery.data.stale === true) return
|
||||
|
||||
return toSchedule(getScheduleQuery.data, walletInfo.jars)
|
||||
}, [getScheduleQuery.error, getScheduleQuery.data, walletInfo.jars])
|
||||
|
||||
const schedulerRunning = takerInfo.scheduler.running
|
||||
const isWaitingSchedulerStart =
|
||||
startScheduleMutationIsPending || (startScheduleMutationIsSuccess && !schedulerRunning)
|
||||
const waitingForTumblerStatus = jmSession?.coinjoin_in_process === true && getScheduleQuery.isPending
|
||||
const waitingForTumblerStatus = takerInfo.running && getScheduleQuery.isPending
|
||||
const singleCoinJoinRunning =
|
||||
jmSession?.coinjoin_in_process === true && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus
|
||||
const makerRunning = jmSession?.maker_running === true
|
||||
const collaborativeOperationRunning = makerRunning || jmSession?.coinjoin_in_process === true
|
||||
takerInfo.running && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus
|
||||
const makerRunning = makerInfo.running === true
|
||||
const collaborativeOperationRunning = makerRunning || takerInfo.running
|
||||
|
||||
const isWaitingSchedulerStop = stopScheduleMutationIsPending || (stopScheduleMutationIsSuccess && schedulerRunning)
|
||||
|
||||
|
|
@ -279,57 +226,24 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
const isOperationDisabled =
|
||||
feeConfigValidation.maxFeesConfigMissing ||
|
||||
collaborativeOperationRunning ||
|
||||
jmSession?.rescanning ||
|
||||
rescanInfo.rescanning ||
|
||||
!preconditionSummary.isFulfilled
|
||||
|
||||
const isStartDisabled =
|
||||
isOperationDisabled ||
|
||||
isWaitingSchedulerStart ||
|
||||
isWaitingSchedulerStop ||
|
||||
hasDestinationErrors ||
|
||||
!allDestinationAddressesPresent
|
||||
|
||||
const onInsecureTestingToggleChange = (checked: boolean) => {
|
||||
setUseInsecureTestingSettings(checked)
|
||||
|
||||
if (checked) {
|
||||
replace([{ address: getNewTestingDestinationAddress(walletInfo.addressSummary) }])
|
||||
void trigger('destinations')
|
||||
return
|
||||
}
|
||||
|
||||
replace(buildSweepDestinationValues(DESTINATION_ADDRESS_COUNT_PROD))
|
||||
void trigger('destinations')
|
||||
}
|
||||
!getScheduleQuery.data ||
|
||||
startScheduleMutationIsPending ||
|
||||
planSchedule.isPending ||
|
||||
deleteScheduleMutationIsPending
|
||||
|
||||
const startSchedule = async () => {
|
||||
if (isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop) {
|
||||
return
|
||||
}
|
||||
if (showScheduleConfirmDialog === undefined) return
|
||||
|
||||
await form.handleSubmit(async (values) => {
|
||||
const body = {
|
||||
destinations: getSweepDestinationAddresses(values),
|
||||
...(showInsecureScheduleTestingToggle && useInsecureTestingSettings
|
||||
? { parameters: INSECURE_SCHEDULE_TUMBLER_OPTIONS }
|
||||
: {}),
|
||||
}
|
||||
|
||||
await startScheduleMutationMutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body,
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
const onOpenScheduleConfirm = async () => {
|
||||
if (isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop) {
|
||||
return
|
||||
}
|
||||
|
||||
await form.handleSubmit(() => {
|
||||
setShowScheduleConfirmDialog(true)
|
||||
})()
|
||||
await startScheduleMutationMutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
})
|
||||
}
|
||||
|
||||
const stopSchedule = async () => {
|
||||
|
|
@ -338,6 +252,12 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
})
|
||||
}
|
||||
|
||||
const deleteSchedule = async () => {
|
||||
await deleteScheduleMutationMutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
})
|
||||
}
|
||||
|
||||
if (!jmSession || feeConfigValidation.isLoading || walletInfo.isLoading) {
|
||||
return <PageLoading />
|
||||
}
|
||||
|
|
@ -351,10 +271,10 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
onOpenChange={setShowFeeConfigDialog}
|
||||
/>
|
||||
<SweepStartConfirmDialog
|
||||
open={showScheduleConfirmDialog}
|
||||
onOpenChange={setShowScheduleConfirmDialog}
|
||||
open={showScheduleConfirmDialog !== undefined}
|
||||
onOpenChange={() => setShowScheduleConfirmDialog(undefined)}
|
||||
onConfirm={startSchedule}
|
||||
disabled={isStartDisabled || isWaitingSchedulerStart}
|
||||
disabled={isStartDisabled}
|
||||
isStarting={isWaitingSchedulerStart}
|
||||
/>
|
||||
<div className="mx-auto max-w-4xl space-y-3 p-4">
|
||||
|
|
@ -388,25 +308,133 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
{isWaitingSchedulerStart && (
|
||||
<Alert>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
<AlertTitle>{t('scheduler.button_start')}</AlertTitle>
|
||||
<AlertTitle>{t('scheduler.alert_scheduler_starting_title')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isWaitingSchedulerStop && (
|
||||
<Alert>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
<AlertTitle>{t('scheduler.button_stop')}</AlertTitle>
|
||||
<AlertTitle>{t('scheduler.alert_scheduler_stopping_title')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{schedulerRunning && currentSchedule && (
|
||||
<SweepScheduleProgress schedule={currentSchedule} isStopping={isWaitingSchedulerStop} onStop={stopSchedule} />
|
||||
{!isWaitingSchedulerStart && !isWaitingSchedulerStop && !schedulerRunning && (
|
||||
<SweepPreconditionAlert summary={preconditionSummary} />
|
||||
)}
|
||||
|
||||
{!schedulerRunning && (
|
||||
{currentSchedule && (
|
||||
<>
|
||||
<SweepPreconditionAlert summary={preconditionSummary} />
|
||||
{getScheduleQuery.isPending ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{schedulerRunning && currentSchedule.summary.status.completed ? (
|
||||
<Alert variant="success">
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
<AlertTitle>{t('scheduler.progress_done_awaiting_stop')}</AlertTitle>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SweepScheduleProgress schedule={currentSchedule} debug={isDeveloperMode} />
|
||||
|
||||
{schedulerRunning && currentSchedule.summary.status.running ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void stopSchedule()}
|
||||
disabled={isWaitingSchedulerStop}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{isWaitingSchedulerStop ? (
|
||||
<>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{t('scheduler.button_stop')}
|
||||
</>
|
||||
) : (
|
||||
t('scheduler.button_stop')
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{currentSchedule.summary.status.pending || isWaitingSchedulerStart ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowScheduleConfirmDialog(getScheduleQuery.data)
|
||||
}}
|
||||
disabled={isStartDisabled}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{isWaitingSchedulerStart ? (
|
||||
<>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{t('scheduler.button_start')}
|
||||
</>
|
||||
) : (
|
||||
t('scheduler.button_start')
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{currentSchedule.summary.status.pending && planSchedule.variables?.body ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void planSchedule.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: planSchedule.variables.body,
|
||||
})
|
||||
}}
|
||||
disabled={isStartDisabled}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{planSchedule.isPending ? (
|
||||
<>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{t('scheduler.button_plan_renew')}
|
||||
</>
|
||||
) : (
|
||||
t('scheduler.button_plan_renew')
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant={currentSchedule.summary.derivedStatus.terminated ? 'default' : 'ghost'}
|
||||
onClick={() => void deleteSchedule()}
|
||||
disabled={
|
||||
!getScheduleQuery.data ||
|
||||
startScheduleMutationIsPending ||
|
||||
planSchedule.isPending ||
|
||||
deleteScheduleMutationIsPending
|
||||
}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{deleteScheduleMutationIsPending ? (
|
||||
<>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{currentSchedule.summary.derivedStatus.terminated ? t('global.done') : t('global.cancel')}
|
||||
</>
|
||||
) : (
|
||||
<>{currentSchedule.summary.derivedStatus.terminated ? t('global.done') : t('global.cancel')}</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!currentSchedule && !schedulerRunning && (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="bg-muted/50 flex flex-col items-start justify-between gap-2 rounded-lg border px-4 py-3 sm:flex-row sm:items-center">
|
||||
|
|
@ -421,54 +449,51 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
|
||||
<p className="text-muted-foreground text-sm">{t('scheduler.description_destination_addresses')}</p>
|
||||
|
||||
{showInsecureScheduleTestingToggle && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="switch-use-insecure-schedule-testing"
|
||||
checked={useInsecureTestingSettings}
|
||||
onCheckedChange={onInsecureTestingToggleChange}
|
||||
disabled={isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop}
|
||||
/>
|
||||
<Label htmlFor="switch-use-insecure-schedule-testing" className="flex flex-col items-start gap-0">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
Use insecure testing settings
|
||||
<DevBadge />
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
This is completely insecure but makes testing the schedule much faster.
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SweepDestinationInputs
|
||||
form={form}
|
||||
fields={fields}
|
||||
<SweepForm
|
||||
jars={walletInfo.jars}
|
||||
addressSummary={walletInfo.addressSummary}
|
||||
disabled={isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop}
|
||||
debug={isDeveloperMode}
|
||||
onSubmit={async (values) => {
|
||||
const parameters: Partial<TumblerParameters> = {
|
||||
...formValuesToTumblerParameters(values),
|
||||
...(values.useInsecureTestingSettings ? { ...INSECURE_SCHEDULE_TUMBLER_OPTIONS } : {}),
|
||||
}
|
||||
|
||||
const body: TumblerPlanRequest = {
|
||||
force: true,
|
||||
destinations: values.destinations.map((it) => it.address),
|
||||
parameters,
|
||||
}
|
||||
|
||||
await planSchedule.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">{t('scheduler.description_fees')}</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void onOpenScheduleConfirm()}
|
||||
disabled={isStartDisabled}
|
||||
size="xxl"
|
||||
className="w-full"
|
||||
>
|
||||
{isWaitingSchedulerStart ? (
|
||||
<>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{t('scheduler.button_start')}
|
||||
</>
|
||||
) : (
|
||||
t('scheduler.button_start')
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isDeveloperMode && (
|
||||
<Card className="mt-8">
|
||||
<CardHeader className="grid">
|
||||
<DevBadge className="justify-self-end" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="overflow-scroll">
|
||||
<code className="text-destructive">getScheduleQuery.data:</code>
|
||||
<pre className="text-xs">{JSON.stringify(getScheduleQuery.data, null, 2)}</pre>
|
||||
</div>
|
||||
<div className="overflow-scroll">
|
||||
<code className="text-destructive">planSchedule.data:</code>
|
||||
<pre className="text-xs">{JSON.stringify(planSchedule.data, null, 2)}</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SweepScheduleProgress } from './SweepScheduleProgress'
|
||||
import type { Schedule } from './scheduleUtils'
|
||||
import { toSchedule } from './scheduleUtils'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record<string, unknown> }) => (
|
||||
|
|
@ -16,40 +15,285 @@ vi.mock('react-i18next', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Address', () => ({
|
||||
Address: ({ value }: { value?: string }) => <span data-testid="address">{value}</span>,
|
||||
}))
|
||||
|
||||
describe('SweepScheduleProgress', () => {
|
||||
it('renders active schedule progress and stops it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onStop = vi.fn().mockResolvedValue(undefined)
|
||||
const schedule: Schedule = [
|
||||
[0, 0, 8, 'INTERNAL', 80, 16, 1],
|
||||
[1, 0, 8, 'tx-destination', 5, 16, '8'.repeat(64)],
|
||||
[2, 0, 8, 'final-destination', 0, 16, 0],
|
||||
]
|
||||
it('renders active schedule progress and stops it', () => {
|
||||
const schedule = toSchedule(
|
||||
{
|
||||
plan_id: 'any',
|
||||
wallet_name: 'wallet.jmdat',
|
||||
status: 'running',
|
||||
destinations: ['final-destination'],
|
||||
current_phase: 0,
|
||||
phases: [
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 0,
|
||||
status: 'completed',
|
||||
wait_seconds: 80 * 60,
|
||||
started_at: '2026-07-19T10:35:52.775747+00:00',
|
||||
finished_at: '2026-07-19T10:36:17.645587+00:00',
|
||||
error: null,
|
||||
mixdepth: 0,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 8,
|
||||
destination: 'INTERNAL',
|
||||
txid: '1'.repeat(64),
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 1,
|
||||
status: 'running',
|
||||
wait_seconds: 5 * 60,
|
||||
started_at: '2026-07-19T10:39:11.684475+00:00',
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 1,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 8,
|
||||
destination: 'INTERNAL',
|
||||
txid: '2'.repeat(64),
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 2,
|
||||
status: 'pending',
|
||||
wait_seconds: 0,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 2,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 8,
|
||||
destination: 'final-destination',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
],
|
||||
created_at: '2009-01-03T10:35:51.466560+00:00',
|
||||
updated_at: '2009-01-03T10:35:52.775780+00:00',
|
||||
error: null,
|
||||
stale: false,
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
render(<SweepScheduleProgress schedule={schedule} isStopping={false} onStop={onStop} />)
|
||||
render(<SweepScheduleProgress schedule={schedule} />)
|
||||
|
||||
expect(screen.getByText(/scheduler.progress_tldr_hours/u)).toBeInTheDocument()
|
||||
expect(screen.getByText(/scheduler.progress_current_state_waiting_confirmation/u)).toBeInTheDocument()
|
||||
expect(screen.getByText(/scheduler.progress_entry_state_confirmed/u)).toBeInTheDocument()
|
||||
expect(screen.getByText(/scheduler.progress_entry_state_waiting_confirmation/u)).toBeInTheDocument()
|
||||
expect(screen.getByText(/scheduler.progress_entry_wait_final/u)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'scheduler.button_stop' }))
|
||||
expect(onStop).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('scheduler.progress_tldr_hours:{"length":"3","hours":"3"}')).toBeInTheDocument()
|
||||
expect(screen.getByText(/scheduler.progress_current_state_waiting_confirmation_title/u)).toBeInTheDocument()
|
||||
expect(screen.getByText(/scheduler.progress_current_state_waiting_confirmation_description/u)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows done and stopping states', () => {
|
||||
const schedule: Schedule = [
|
||||
[0, 0, 8, 'INTERNAL', 0.25, 16, 1],
|
||||
[1, 0, 8, 'final-destination', 0, 16, 1],
|
||||
]
|
||||
it('shows completed schedule', () => {
|
||||
const schedule = toSchedule(
|
||||
{
|
||||
plan_id: 'any',
|
||||
wallet_name: 'wallet.jmdat',
|
||||
status: 'completed',
|
||||
destinations: ['final-destination'],
|
||||
current_phase: 1,
|
||||
phases: [
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 0,
|
||||
status: 'completed',
|
||||
wait_seconds: 60,
|
||||
started_at: '2026-07-19T10:35:52.775747+00:00',
|
||||
finished_at: '2026-07-19T10:36:17.645587+00:00',
|
||||
error: null,
|
||||
mixdepth: 0,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 8,
|
||||
destination: 'INTERNAL',
|
||||
txid: '1'.repeat(64),
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 1,
|
||||
status: 'completed',
|
||||
wait_seconds: 0,
|
||||
started_at: '2026-07-19T11:35:52.775747+00:00',
|
||||
finished_at: '2026-07-19T11:36:17.645587+00:00',
|
||||
error: null,
|
||||
mixdepth: 2,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 8,
|
||||
destination: 'final-destination',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
],
|
||||
created_at: '2009-01-03T10:35:51.466560+00:00',
|
||||
updated_at: '2009-01-03T10:35:52.775780+00:00',
|
||||
error: null,
|
||||
stale: false,
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const { rerender } = render(<SweepScheduleProgress schedule={schedule} isStopping={false} onStop={vi.fn()} />)
|
||||
render(<SweepScheduleProgress schedule={schedule} />)
|
||||
|
||||
expect(screen.getByText(/scheduler.progress_tldr_seconds/u)).toBeInTheDocument()
|
||||
expect(screen.getByText('scheduler.progress_done')).toBeInTheDocument()
|
||||
expect(screen.getByText('scheduler.progress_tldr_hours:{"length":"2","hours":"2"}')).toBeInTheDocument()
|
||||
expect(screen.getByText('Scheduled sweep finished successfully.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
rerender(<SweepScheduleProgress schedule={schedule} isStopping onStop={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: /scheduler\.button_stop/u })).toBeDisabled()
|
||||
it('shows failed schedule', () => {
|
||||
const schedule = toSchedule(
|
||||
{
|
||||
plan_id: 'any',
|
||||
wallet_name: 'wallet.jmdat',
|
||||
status: 'failed',
|
||||
destinations: ['final-destination'],
|
||||
current_phase: 1,
|
||||
phases: [
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 0,
|
||||
status: 'completed',
|
||||
wait_seconds: 30,
|
||||
started_at: '2026-07-19T10:35:52.775747+00:00',
|
||||
finished_at: '2026-07-19T10:36:17.645587+00:00',
|
||||
error: null,
|
||||
mixdepth: 0,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 8,
|
||||
destination: 'INTERNAL',
|
||||
txid: '1'.repeat(64),
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 1,
|
||||
status: 'failed',
|
||||
wait_seconds: 0,
|
||||
started_at: '2026-07-19T11:35:52.775747+00:00',
|
||||
finished_at: '2026-07-19T11:36:17.645587+00:00',
|
||||
error: null,
|
||||
mixdepth: 2,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 8,
|
||||
destination: 'final-destination',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
],
|
||||
created_at: '2009-01-03T10:35:51.466560+00:00',
|
||||
updated_at: '2009-01-03T10:35:52.775780+00:00',
|
||||
error: null,
|
||||
stale: false,
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
render(<SweepScheduleProgress schedule={schedule} />)
|
||||
|
||||
expect(screen.getByText('scheduler.progress_tldr_hours:{"length":"2","hours":"2"}')).toBeInTheDocument()
|
||||
expect(screen.getByText('Scheduled sweep failed.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows cancelled schedule', () => {
|
||||
const schedule = toSchedule(
|
||||
{
|
||||
plan_id: 'any',
|
||||
wallet_name: 'wallet.jmdat',
|
||||
status: 'cancelled',
|
||||
destinations: ['final-destination'],
|
||||
current_phase: 1,
|
||||
phases: [
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 0,
|
||||
status: 'completed',
|
||||
wait_seconds: 30,
|
||||
started_at: '2026-07-19T10:35:52.775747+00:00',
|
||||
finished_at: '2026-07-19T10:36:17.645587+00:00',
|
||||
error: null,
|
||||
mixdepth: 0,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 8,
|
||||
destination: 'INTERNAL',
|
||||
txid: '1'.repeat(64),
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 1,
|
||||
status: 'cancelled',
|
||||
wait_seconds: 0,
|
||||
started_at: '2026-07-19T11:35:52.775747+00:00',
|
||||
finished_at: '2026-07-19T11:36:17.645587+00:00',
|
||||
error: null,
|
||||
mixdepth: 2,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 8,
|
||||
destination: 'final-destination',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
],
|
||||
created_at: '2009-01-03T10:35:51.466560+00:00',
|
||||
updated_at: '2009-01-03T10:35:52.775780+00:00',
|
||||
error: null,
|
||||
stale: false,
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
render(<SweepScheduleProgress schedule={schedule} />)
|
||||
|
||||
expect(screen.getByText('scheduler.progress_tldr_hours:{"length":"2","hours":"2"}')).toBeInTheDocument()
|
||||
expect(screen.getByText('Scheduled sweep cancelled.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,30 +1,40 @@
|
|||
import { useState } from 'react'
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CalendarCheck2Icon,
|
||||
CalendarClockIcon,
|
||||
CheckCircle2Icon,
|
||||
CheckIcon,
|
||||
ClockAlertIcon,
|
||||
CopyIcon,
|
||||
HourglassIcon,
|
||||
} from 'lucide-react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, shortenStringMiddle } from '@/lib/utils'
|
||||
import { DevBadge } from '../dev/DevBadge'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'
|
||||
import { buttonVariants } from '../ui/button-variants'
|
||||
import { Item, ItemContent, ItemGroup, ItemTitle } from '../ui/item'
|
||||
import { Address } from '../ui/jam/Address'
|
||||
import { CopyButton } from '../ui/jam/CopyButton'
|
||||
import { Label } from '../ui/label'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
import type { Schedule, ScheduleEntryState } from './scheduleUtils'
|
||||
import { toScheduleProgressSummary } from './scheduleUtils'
|
||||
|
||||
interface SweepScheduleProgressProps {
|
||||
schedule: Schedule
|
||||
isStopping: boolean
|
||||
onStop: () => Promise<void>
|
||||
}
|
||||
|
||||
const SweepProgressBar = ({ schedule }: { schedule: Schedule }) => {
|
||||
const progress = toScheduleProgressSummary(schedule)
|
||||
import { Tabs, TabsList, TabsTrigger } from '../ui/tabs'
|
||||
import { ScheduleEntryItem } from './ScheduleEntryItem'
|
||||
import type { Schedule, ScheduleProgressSummary } from './scheduleUtils'
|
||||
|
||||
const SweepProgressBar = ({ progress }: { progress: ScheduleProgressSummary }) => {
|
||||
return (
|
||||
<div className="bg-muted flex h-3 w-full overflow-hidden rounded-full">
|
||||
{progress.steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn('border-r-background h-full border-r-[1px] transition-all last:border-r-0', {
|
||||
'bg-primary/35': !step.isComplete && !step.isActive,
|
||||
'bg-primary motion-safe:animate-pulse': step.isActive,
|
||||
'bg-primary': step.isComplete,
|
||||
'bg-primary/35': !step.completed && !step.active,
|
||||
'bg-primary motion-safe:animate-pulse': step.active,
|
||||
'bg-primary': step.completed,
|
||||
})}
|
||||
style={{ width: `${step.widthPercent}%` }}
|
||||
/>
|
||||
|
|
@ -33,39 +43,27 @@ const SweepProgressBar = ({ schedule }: { schedule: Schedule }) => {
|
|||
)
|
||||
}
|
||||
|
||||
export const SweepScheduleProgress = ({ schedule, isStopping, onStop }: SweepScheduleProgressProps) => {
|
||||
const alertTitleComponents = {
|
||||
'1': <span className="font-semibold" />,
|
||||
'3': <span className="font-semibold" />,
|
||||
}
|
||||
const alertDescriptionWaitingForConfirmationsComponents = {
|
||||
'1': <span className="font-mono" />,
|
||||
}
|
||||
|
||||
type Tab = 'completed' | 'pending' | 'all'
|
||||
|
||||
interface SweepScheduleProgressProps {
|
||||
schedule: Schedule
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export const SweepScheduleProgress = ({ schedule, debug }: SweepScheduleProgressProps) => {
|
||||
const { t } = useTranslation()
|
||||
const progress = toScheduleProgressSummary(schedule)
|
||||
const totalHours = Math.ceil(progress.totalWaitSeconds / 60 / 60)
|
||||
const totalSeconds = Math.ceil(progress.totalWaitSeconds)
|
||||
const highlightedComponents = {
|
||||
'1': <span className="font-semibold" />,
|
||||
'3': <span className="font-semibold" />,
|
||||
}
|
||||
const totalHours = Math.ceil(schedule.summary.estimatedTotalDurationInSeconds / 60 / 60)
|
||||
const totalSeconds = Math.ceil(schedule.summary.estimatedTotalDurationInSeconds)
|
||||
|
||||
const formatWaitTime = (seconds: number): string => {
|
||||
const roundedSeconds = Math.max(0, Math.ceil(seconds))
|
||||
const minutes = Math.floor(roundedSeconds / 60)
|
||||
const remainingSeconds = roundedSeconds % 60
|
||||
|
||||
if (minutes === 0) {
|
||||
return t('scheduler.progress_wait_seconds', { seconds: remainingSeconds })
|
||||
}
|
||||
if (remainingSeconds === 0) {
|
||||
return t('scheduler.progress_wait_minutes', { minutes })
|
||||
}
|
||||
return t('scheduler.progress_wait_minutes_seconds', { minutes, seconds: remainingSeconds })
|
||||
}
|
||||
|
||||
const toScheduleEntryStateText = (state: ScheduleEntryState, txid?: string): string => {
|
||||
if (state === 'confirmed') {
|
||||
return t('scheduler.progress_entry_state_confirmed')
|
||||
}
|
||||
if (state === 'broadcasted') {
|
||||
return t('scheduler.progress_entry_state_waiting_confirmation', { txid: txid ?? '-' })
|
||||
}
|
||||
return t('scheduler.progress_entry_state_pending')
|
||||
}
|
||||
const [activeTab, setActiveTab] = useState<Tab>(() => 'all')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -75,110 +73,310 @@ export const SweepScheduleProgress = ({ schedule, isStopping, onStop }: SweepSch
|
|||
<p className="text-sm">
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_tldr_seconds"
|
||||
values={{ length: progress.totalTransactions, seconds: totalSeconds }}
|
||||
components={highlightedComponents}
|
||||
values={{ length: schedule.entries.length.toLocaleString(), seconds: totalSeconds.toLocaleString() }}
|
||||
components={alertTitleComponents}
|
||||
/>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_tldr_hours"
|
||||
values={{ length: progress.totalTransactions, hours: totalHours }}
|
||||
components={highlightedComponents}
|
||||
values={{ length: schedule.entries.length.toLocaleString(), hours: totalHours.toLocaleString() }}
|
||||
components={alertTitleComponents}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">{t('scheduler.progress_description')}</p>
|
||||
</div>
|
||||
|
||||
<SweepProgressBar schedule={schedule} />
|
||||
<SweepProgressBar progress={schedule.progress} />
|
||||
|
||||
{progress.isDone ? (
|
||||
{schedule.summary.status.completed ? (
|
||||
<Alert variant="success">
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
<AlertTitle>{t('scheduler.progress_done')}</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
<AlertTitle>
|
||||
{/* Keep a stable fallback state so brief polling gaps never leave this header empty. */}
|
||||
{progress.currentState?.type === 'waiting_before_next' ? (
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_wait_before_next"
|
||||
values={{
|
||||
current: progress.currentState.currentTransaction,
|
||||
total: progress.currentState.totalTransactions,
|
||||
wait: formatWaitTime(progress.currentState.waitSeconds ?? 0),
|
||||
}}
|
||||
components={highlightedComponents}
|
||||
/>
|
||||
) : progress.currentState?.type === 'waiting_for_confirmation' ? (
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_waiting_confirmation"
|
||||
values={{
|
||||
current: progress.currentState.currentTransaction,
|
||||
total: progress.currentState.totalTransactions,
|
||||
txid: progress.currentState.txid ?? '-',
|
||||
}}
|
||||
components={highlightedComponents}
|
||||
/>
|
||||
) : progress.currentState?.type === 'transaction_confirmed' ? (
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_transaction_confirmed"
|
||||
values={{
|
||||
current: progress.currentState.currentTransaction,
|
||||
total: progress.currentState.totalTransactions,
|
||||
}}
|
||||
components={highlightedComponents}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_creating_next"
|
||||
values={{
|
||||
current: progress.currentTransactionIndex + 1,
|
||||
total: progress.totalTransactions,
|
||||
}}
|
||||
components={highlightedComponents}
|
||||
/>
|
||||
)}
|
||||
</AlertTitle>
|
||||
<CheckCircle2Icon />
|
||||
<AlertTitle>{/* TODO: i18n */ 'Scheduled sweep finished successfully.'}</AlertTitle>
|
||||
<AlertDescription />
|
||||
</Alert>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<div className="font-medium">{t('scheduler.progress_schedule_info_title')}</div>
|
||||
<div className="space-y-2">
|
||||
{progress.entries.map((entry) => (
|
||||
<div
|
||||
key={entry.index}
|
||||
className="bg-muted/30 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 rounded-md px-2 py-1.5 text-xs"
|
||||
>
|
||||
<div className="font-medium">{t('scheduler.progress_entry_label', { index: entry.index + 1 })}</div>
|
||||
<div className="text-muted-foreground">{toScheduleEntryStateText(entry.state, entry.txid)}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{entry.isLast
|
||||
? t('scheduler.progress_entry_wait_final')
|
||||
: t('scheduler.progress_entry_wait_before_next', {
|
||||
wait: formatWaitTime(entry.waitBeforeNextSeconds),
|
||||
})}
|
||||
{schedule.summary.status.failed ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{/* TODO: i18n */ 'Scheduled sweep failed.'}</AlertTitle>
|
||||
<AlertDescription />
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{schedule.summary.status.cancelled ? (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{/* TODO: i18n */ 'Scheduled sweep cancelled.'}</AlertTitle>
|
||||
<AlertDescription />
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{schedule.summary.status.pending ? (
|
||||
<Alert variant="default">
|
||||
<ClockAlertIcon />
|
||||
<AlertTitle>{/* TODO: i18n */ 'Scheduled sweep pending.'}</AlertTitle>
|
||||
<AlertDescription />
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{schedule.summary.status.running ? (
|
||||
<>
|
||||
{/* Keep a stable fallback state so brief polling gaps never leave this header empty. */}
|
||||
{schedule.summary.derivedStatus.value === 'waiting_for_confirmation' ? (
|
||||
<Alert>
|
||||
<HourglassIcon className="motion-safe:animate-pulse" />
|
||||
<AlertTitle>
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_waiting_confirmation_title"
|
||||
values={{
|
||||
current: schedule.active ? (schedule.active.index + 1).toLocaleString() : undefined,
|
||||
total: schedule.entries.length.toLocaleString(),
|
||||
txid: schedule.active?.transactionId
|
||||
? shortenStringMiddle(schedule.active.transactionId, 12)
|
||||
: '-',
|
||||
}}
|
||||
components={alertTitleComponents}
|
||||
/>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_waiting_confirmation_description"
|
||||
values={{
|
||||
txid: schedule.active?.transactionId
|
||||
? shortenStringMiddle(schedule.active.transactionId, 12)
|
||||
: '-',
|
||||
}}
|
||||
components={alertDescriptionWaitingForConfirmationsComponents}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : schedule.summary.derivedStatus.value === 'waiting_before_next' ? (
|
||||
<Alert>
|
||||
<HourglassIcon className="motion-safe:animate-pulse" />
|
||||
<AlertTitle>
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_waiting_start_title"
|
||||
values={{
|
||||
current: schedule.active ? (schedule.active.index + 1).toLocaleString() : undefined,
|
||||
total: schedule.entries.length,
|
||||
}}
|
||||
components={alertTitleComponents}
|
||||
/>
|
||||
</AlertTitle>
|
||||
<AlertDescription />
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
<AlertTitle>
|
||||
<Trans
|
||||
i18nKey="scheduler.progress_current_state_executing_title"
|
||||
values={{
|
||||
current: schedule.active ? (schedule.active.index + 1).toLocaleString() : undefined,
|
||||
total: schedule.entries.length,
|
||||
}}
|
||||
components={alertTitleComponents}
|
||||
/>
|
||||
</AlertTitle>
|
||||
<AlertDescription />
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{schedule.summary.startedAt ? (
|
||||
<Item variant="outline">
|
||||
<ItemContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<CalendarClockIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'Started At'}</Label>
|
||||
<span title={schedule.summary.startedAt.toISOString()}>
|
||||
{schedule.summary.startedAt.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<CalendarCheck2Icon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'Finished At'}</Label>
|
||||
{schedule.summary.finishedAt === undefined ? (
|
||||
'-'
|
||||
) : (
|
||||
<span title={schedule.summary.finishedAt.toISOString()}>
|
||||
{schedule.summary.finishedAt.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<Button type="button" onClick={() => void onStop()} disabled={isStopping} size="lg" className="w-full">
|
||||
{isStopping ? (
|
||||
<>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{t('scheduler.button_stop')}
|
||||
</>
|
||||
) : (
|
||||
t('scheduler.button_stop')
|
||||
)}
|
||||
</Button>
|
||||
{schedule.active !== undefined ? (
|
||||
<Accordion type="single" collapsible defaultValue="active">
|
||||
<AccordionItem value="active">
|
||||
<AccordionTrigger>
|
||||
{
|
||||
/* TODO: i18n */ t('scheduler.section_active_action_title', {
|
||||
defaultValue: 'Active action',
|
||||
})
|
||||
}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
className={cn('flex flex-col gap-6 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
|
||||
>
|
||||
<ItemGroup className="space-y-2">
|
||||
<ScheduleEntryItem value={schedule.active} active />
|
||||
</ItemGroup>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
) : null}
|
||||
|
||||
{schedule.summary.externalDestinations.length === 0 ? null : (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="destinations">
|
||||
<AccordionTrigger>
|
||||
{
|
||||
/* TODO: i18n */ t('scheduler.section_destinations_title', {
|
||||
defaultValue: 'Destinations',
|
||||
})
|
||||
}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
className={cn('flex flex-col gap-2 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
|
||||
>
|
||||
<p className="text-muted-foreground">{t('scheduler.description_destination_addresses')}</p>
|
||||
{schedule.summary.externalDestinations.map((value, index) => {
|
||||
return (
|
||||
<Item key={index} variant={value.transactionId ? 'muted' : 'outline'}>
|
||||
<ItemContent className="space-y-2">
|
||||
<ItemTitle className="flex-col items-start gap-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'Address'}</Label>
|
||||
<Address value={value.address} />
|
||||
</ItemTitle>
|
||||
{value.transactionId ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="font-semibold">{/*TODO: i18n */ 'Transaction ID'}</Label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-md block font-mono break-all select-all">
|
||||
{value.transactionId}
|
||||
</span>
|
||||
<CopyButton
|
||||
value={value.transactionId}
|
||||
text={<CopyIcon />}
|
||||
successText={<CheckIcon className="text-brand-success" />}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'icon-xs' }), 'shrink-0')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="details">
|
||||
<AccordionTrigger>
|
||||
{
|
||||
/* TODO: i18n */ t('scheduler.section_details_title', {
|
||||
defaultValue: 'Schedule details',
|
||||
})
|
||||
}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
className={cn('flex flex-col gap-6 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as Tab)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<TabsList className="mx-auto flex items-center gap-2">
|
||||
<TabsTrigger value="pending" className="cursor-pointer">
|
||||
{/* TODO: i18n */}Pending ({schedule.pending.length.toLocaleString()})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="completed" className="cursor-pointer">
|
||||
{/* TODO: i18n */}Completed ({schedule.completed.length.toLocaleString()})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all" className="cursor-pointer">
|
||||
{/* TODO: i18n */}All ({schedule.entries.length.toLocaleString()})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{activeTab === 'completed' ? (
|
||||
schedule.completed.length === 0 ? (
|
||||
<>
|
||||
<div className="m-2 flex items-center justify-center gap-2">
|
||||
No completed actions yet.{/* TODO: i18n */}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ItemGroup className="space-y-2">
|
||||
{schedule.completed.map((entry, index) => {
|
||||
return <ScheduleEntryItem key={index} value={entry} active={entry === schedule.active} />
|
||||
})}
|
||||
</ItemGroup>
|
||||
)
|
||||
) : null}
|
||||
{activeTab === 'pending' ? (
|
||||
schedule.pending.length === 0 ? (
|
||||
<>
|
||||
<div className="m-2 flex items-center justify-center gap-2">
|
||||
No pending actions.{/* TODO: i18n */}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ItemGroup className="space-y-2">
|
||||
{schedule.pending.map((entry, index) => {
|
||||
return <ScheduleEntryItem key={index} value={entry} active={entry === schedule.active} />
|
||||
})}
|
||||
</ItemGroup>
|
||||
)
|
||||
) : null}
|
||||
{activeTab === 'all' ? (
|
||||
<ItemGroup className="space-y-2">
|
||||
{schedule.entries.map((entry, index) => {
|
||||
return <ScheduleEntryItem key={index} value={entry} active={entry === schedule.active} />
|
||||
})}
|
||||
</ItemGroup>
|
||||
) : null}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{debug && (
|
||||
<Card className="mt-8">
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="debug">
|
||||
<AccordionTrigger>
|
||||
<div>
|
||||
Debug <DevBadge className="justify-self-end" />
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="overflow-scroll">
|
||||
<code className="text-destructive">schedule:</code>
|
||||
<pre className="text-xs">{JSON.stringify(schedule, null, 2)}</pre>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ describe('SweepStartConfirmDialog', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('scheduler.confirm_modal.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('scheduler.confirm_modal.body')).toBeInTheDocument()
|
||||
expect(screen.getByText('scheduler.confirm_start_modal.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('scheduler.confirm_start_modal.text_body')).toBeInTheDocument()
|
||||
expect(screen.getByText('modal.confirm_button_reject')).toBeInTheDocument()
|
||||
expect(screen.getByText('modal.confirm_button_accept')).toBeInTheDocument()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ export const SweepStartConfirmDialog = ({
|
|||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('scheduler.confirm_modal.title')}</DialogTitle>
|
||||
<DialogTitle>{t('scheduler.confirm_start_modal.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-muted-foreground text-sm">{t('scheduler.confirm_modal.body')}</p>
|
||||
<p className="text-muted-foreground text-sm">{t('scheduler.confirm_start_modal.text_body')}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isStarting}>
|
||||
{t('modal.confirm_button_reject')}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@ export const buildDestinationErrors = (
|
|||
}
|
||||
|
||||
const isDuplicateAddress = (counts.get(address) ?? 0) > 1
|
||||
|
||||
if (isReusedAddress(address, addressSummary) || isDuplicateAddress) {
|
||||
if (isDuplicateAddress || isReusedAddress(address, addressSummary)) {
|
||||
return t('scheduler.feedback_reused_destination_address')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { JM_TAKER_UTXO_AGE } from '@/constants/jm'
|
||||
import type { Utxo } from '@/hooks/useQueryUtxos'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
|
||||
|
|
@ -16,11 +17,11 @@ export interface SweepPreconditionSummary {
|
|||
|
||||
const DEFAULT_OPTIONS: SweepPreconditionOptions = {
|
||||
minNumberOfUtxos: 1,
|
||||
minConfirmations: 5,
|
||||
minConfirmations: JM_TAKER_UTXO_AGE,
|
||||
}
|
||||
|
||||
const isUtxoEligible = (utxo: Utxo): boolean => {
|
||||
return !utxo.frozen && !fb.utxo.isLocked(utxo)
|
||||
return !utxo.frozen && !fb.utxo.isFidelityBond(utxo)
|
||||
}
|
||||
|
||||
const groupByJarIndex = (utxos: Utxo[]): Map<number, Utxo[]> => {
|
||||
|
|
@ -33,7 +34,7 @@ const groupByJarIndex = (utxos: Utxo[]): Map<number, Utxo[]> => {
|
|||
}, new Map<number, Utxo[]>())
|
||||
}
|
||||
|
||||
const toRetryLockedUtxos = (eligibleUtxos: Utxo[]): Utxo[] => {
|
||||
const filterUtxosBlockedByRetryLimit = (eligibleUtxos: Utxo[]): Utxo[] => {
|
||||
const utxosByJar = groupByJarIndex(eligibleUtxos)
|
||||
const blockedUtxos: Utxo[] = []
|
||||
|
||||
|
|
@ -47,7 +48,7 @@ const toRetryLockedUtxos = (eligibleUtxos: Utxo[]): Utxo[] => {
|
|||
return blockedUtxos
|
||||
}
|
||||
|
||||
const toNumberOfMissingConfirmations = (utxos: Utxo[], minConfirmations: number): number => {
|
||||
const calcMissingConfirmations = (utxos: Utxo[], minConfirmations: number): number => {
|
||||
if (utxos.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
|
@ -71,9 +72,9 @@ export const buildSweepPreconditionSummary = (
|
|||
const eligibleUtxos = utxos.filter((utxo) => isUtxoEligible(utxo))
|
||||
const numberOfMissingUtxos = Math.max(0, mergedOptions.minNumberOfUtxos - eligibleUtxos.length)
|
||||
const numberOfMissingConfirmations =
|
||||
numberOfMissingUtxos > 0 ? 0 : toNumberOfMissingConfirmations(eligibleUtxos, mergedOptions.minConfirmations)
|
||||
numberOfMissingUtxos > 0 ? 0 : calcMissingConfirmations(eligibleUtxos, mergedOptions.minConfirmations)
|
||||
|
||||
const retryLockedUtxos = toRetryLockedUtxos(eligibleUtxos)
|
||||
const retryLockedUtxos = filterUtxosBlockedByRetryLimit(eligibleUtxos)
|
||||
|
||||
return {
|
||||
isFulfilled: numberOfMissingUtxos === 0 && numberOfMissingConfirmations === 0 && retryLockedUtxos.length === 0,
|
||||
|
|
|
|||
|
|
@ -1,74 +1,563 @@
|
|||
import type { TumblerPlanResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isScheduleLikelyCompletedSuccessfully,
|
||||
isScheduleValue,
|
||||
toScheduleProgressSummary,
|
||||
type Schedule,
|
||||
} from './scheduleUtils'
|
||||
import { toSchedule } from './scheduleUtils'
|
||||
|
||||
describe('scheduleUtils', () => {
|
||||
it('identifies valid schedule values', () => {
|
||||
expect(isScheduleValue([[0, 0, 8, 'INTERNAL', 10, 16, 0]])).toBe(true)
|
||||
expect(isScheduleValue(undefined)).toBe(false)
|
||||
expect(isScheduleValue([{}])).toBe(false)
|
||||
})
|
||||
it('creates schedule from tumbler plan', () => {
|
||||
const response: TumblerPlanResponse = {
|
||||
plan_id: 'any',
|
||||
wallet_name: 'wallet.jmdat',
|
||||
status: 'running',
|
||||
destinations: ['external-address'],
|
||||
current_phase: 0,
|
||||
phases: [
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 0,
|
||||
status: 'running',
|
||||
wait_seconds: 20.8818214051562,
|
||||
started_at: '2009-01-03T10:35:52.775747+00:00',
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 0,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 21,
|
||||
destination: 'INTERNAL',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'maker_session',
|
||||
index: 1,
|
||||
status: 'pending',
|
||||
wait_seconds: 3.005975326668475,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: null,
|
||||
amount: null,
|
||||
amount_fraction: null,
|
||||
counterparty_count: null,
|
||||
destination: null,
|
||||
txid: null,
|
||||
duration_seconds: 43200,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: 60,
|
||||
cj_served: 0,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 2,
|
||||
status: 'pending',
|
||||
wait_seconds: 2.14866055202069,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 1,
|
||||
amount: null,
|
||||
amount_fraction: 0.3907,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 3,
|
||||
status: 'pending',
|
||||
wait_seconds: 0.676048133059885,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 1,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'maker_session',
|
||||
index: 4,
|
||||
status: 'pending',
|
||||
wait_seconds: 1.5946546346591093,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: null,
|
||||
amount: null,
|
||||
amount_fraction: null,
|
||||
counterparty_count: null,
|
||||
destination: null,
|
||||
txid: null,
|
||||
duration_seconds: 43200,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: 60,
|
||||
cj_served: 0,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 5,
|
||||
status: 'pending',
|
||||
wait_seconds: 2.9896632224179864,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 2,
|
||||
amount: null,
|
||||
amount_fraction: 0.4218,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 6,
|
||||
status: 'pending',
|
||||
wait_seconds: 40.54038187838071,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 2,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'maker_session',
|
||||
index: 7,
|
||||
status: 'pending',
|
||||
wait_seconds: 6.403509345560395,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: null,
|
||||
amount: null,
|
||||
amount_fraction: null,
|
||||
counterparty_count: null,
|
||||
destination: null,
|
||||
txid: null,
|
||||
duration_seconds: 43200,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: 60,
|
||||
cj_served: 0,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 8,
|
||||
status: 'pending',
|
||||
wait_seconds: 26.647805903403334,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 3,
|
||||
amount: null,
|
||||
amount_fraction: 0.5576,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 9,
|
||||
status: 'pending',
|
||||
wait_seconds: 2.4477304838778844,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 3,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'maker_session',
|
||||
index: 10,
|
||||
status: 'pending',
|
||||
wait_seconds: 10.08820707258785,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: null,
|
||||
amount: null,
|
||||
amount_fraction: null,
|
||||
counterparty_count: null,
|
||||
destination: null,
|
||||
txid: null,
|
||||
duration_seconds: 43200,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: 60,
|
||||
cj_served: 0,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 11,
|
||||
status: 'pending',
|
||||
wait_seconds: 8.619765466535256,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 4,
|
||||
amount: null,
|
||||
amount_fraction: 0.5066,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 12,
|
||||
status: 'pending',
|
||||
wait_seconds: 0,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 4,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 1,
|
||||
destination: 'different-external-address',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
],
|
||||
created_at: '2009-01-03T10:35:51.466560+00:00',
|
||||
updated_at: '2009-01-03T10:35:52.775780+00:00',
|
||||
error: null,
|
||||
stale: false,
|
||||
}
|
||||
|
||||
it('creates progress summary from schedule entries', () => {
|
||||
const schedule: Schedule = [
|
||||
[0, 0, 8, 'INTERNAL', 10, 16, 1],
|
||||
[1, 0, 8, 'INTERNAL', 5, 16, 0],
|
||||
[2, 0, 8, 'bc1qdestination', 1, 16, 0],
|
||||
]
|
||||
const schedule = toSchedule(response, [])
|
||||
|
||||
const summary = toScheduleProgressSummary(schedule)
|
||||
expect(schedule.completed.length).toBe(0)
|
||||
expect(schedule.entries.length).toBe(13)
|
||||
|
||||
expect(summary.totalTransactions).toBe(3)
|
||||
expect(summary.completedTransactions).toBe(1)
|
||||
expect(summary.currentTransactionIndex).toBe(1)
|
||||
expect(summary.isDone).toBe(false)
|
||||
expect(summary.steps).toHaveLength(3)
|
||||
expect(summary.steps[1].isActive).toBe(true)
|
||||
expect(summary.entries).toHaveLength(3)
|
||||
expect(summary.entries[0].state).toBe('confirmed')
|
||||
expect(summary.entries[0].waitBeforeNextSeconds).toBe(600)
|
||||
expect(summary.entries[2].isLast).toBe(true)
|
||||
expect(schedule.active?.index).toBe(0)
|
||||
expect(schedule.active?.transactionId).toBeUndefined()
|
||||
expect(schedule.active?.internal).toBe(true)
|
||||
expect(schedule.active?.isSweep).toBe(true)
|
||||
expect(schedule.active?.jarIndex).toBe(0)
|
||||
expect(schedule.active?.numberOfRequestedCounterparties).toBe(21)
|
||||
expect(schedule.active?.status).toEqual({
|
||||
value: 'running',
|
||||
pending: false,
|
||||
running: true,
|
||||
completed: false,
|
||||
failed: false,
|
||||
cancelled: false,
|
||||
skipped: false,
|
||||
})
|
||||
|
||||
expect(schedule.summary.externalDestinations).toEqual([
|
||||
{
|
||||
address: 'different-external-address',
|
||||
},
|
||||
])
|
||||
|
||||
expect(schedule.summary.status).toEqual({
|
||||
value: 'running',
|
||||
pending: false,
|
||||
running: true,
|
||||
completed: false,
|
||||
failed: false,
|
||||
cancelled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('derives current state while waiting for transaction confirmation', () => {
|
||||
const schedule: Schedule = [
|
||||
[0, 0, 8, 'INTERNAL', 10, 16, 1],
|
||||
[1, 0, 8, 'INTERNAL', 5, 16, '8'.repeat(64)],
|
||||
[2, 0, 8, 'bc1qdestination', 1, 16, 0],
|
||||
]
|
||||
const response: TumblerPlanResponse = {
|
||||
plan_id: 'any',
|
||||
wallet_name: 'wallet.jmdat',
|
||||
status: 'running',
|
||||
destinations: ['bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk'],
|
||||
current_phase: 0,
|
||||
phases: [
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 0,
|
||||
status: 'completed',
|
||||
wait_seconds: 20.8818214051562,
|
||||
started_at: '2009-01-03T10:35:52.775747+00:00',
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 0,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 42,
|
||||
destination: 'INTERNAL',
|
||||
txid: '8'.repeat(64),
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
],
|
||||
created_at: '2009-01-03T10:35:51.466560+00:00',
|
||||
updated_at: '2009-01-03T10:35:52.775780+00:00',
|
||||
error: null,
|
||||
stale: false,
|
||||
}
|
||||
|
||||
const summary = toScheduleProgressSummary(schedule)
|
||||
const schedule = toSchedule(response, [])
|
||||
expect(schedule.completed.length).toBe(1)
|
||||
|
||||
expect(summary.currentState?.type).toBe('waiting_for_confirmation')
|
||||
expect(summary.currentState?.currentTransaction).toBe(2)
|
||||
expect(summary.entries[1].state).toBe('broadcasted')
|
||||
expect(summary.entries[1].txid).toBe('8'.repeat(64))
|
||||
expect(schedule.active?.index).toBe(0)
|
||||
expect(schedule.active?.transactionId).toBe('8'.repeat(64))
|
||||
expect(schedule.active?.internal).toBe(true)
|
||||
expect(schedule.active?.isSweep).toBe(true)
|
||||
expect(schedule.active?.jarIndex).toBe(0)
|
||||
expect(schedule.active?.numberOfRequestedCounterparties).toBe(42)
|
||||
expect(schedule.active?.status).toEqual({
|
||||
value: 'completed',
|
||||
pending: false,
|
||||
running: false,
|
||||
completed: true,
|
||||
failed: false,
|
||||
cancelled: false,
|
||||
skipped: false,
|
||||
})
|
||||
|
||||
expect(schedule.summary.derivedStatus).toEqual({
|
||||
value: 'waiting_for_confirmation',
|
||||
terminated: false,
|
||||
})
|
||||
expect(schedule.summary.status).toEqual({
|
||||
value: 'running',
|
||||
pending: false,
|
||||
running: true,
|
||||
completed: false,
|
||||
failed: false,
|
||||
cancelled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('derives current state while waiting before the next transaction', () => {
|
||||
const schedule: Schedule = [
|
||||
[0, 0, 8, 'INTERNAL', 2, 16, 1],
|
||||
[1, 0, 8, 'bc1qdestination', 0, 16, 0],
|
||||
]
|
||||
const response: TumblerPlanResponse = {
|
||||
plan_id: 'any',
|
||||
wallet_name: 'wallet.jmdat',
|
||||
status: 'running',
|
||||
destinations: ['bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk'],
|
||||
current_phase: 1,
|
||||
phases: [
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 0,
|
||||
status: 'completed',
|
||||
wait_seconds: 20.8818214051562,
|
||||
started_at: '2009-01-03T10:35:52.775747+00:00',
|
||||
finished_at: '2009-01-03T10:36:17.645587+00:00',
|
||||
error: null,
|
||||
mixdepth: 0,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'maker_session',
|
||||
index: 1,
|
||||
status: 'pending',
|
||||
wait_seconds: 3.005975326668475,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: null,
|
||||
amount: null,
|
||||
amount_fraction: null,
|
||||
counterparty_count: null,
|
||||
destination: null,
|
||||
txid: null,
|
||||
duration_seconds: 42,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: 60,
|
||||
cj_served: 0,
|
||||
attempt_count: 0,
|
||||
},
|
||||
],
|
||||
created_at: '2009-01-03T10:35:51.466560+00:00',
|
||||
updated_at: '2009-01-03T10:35:52.775780+00:00',
|
||||
error: null,
|
||||
stale: false,
|
||||
}
|
||||
|
||||
const summary = toScheduleProgressSummary(schedule)
|
||||
const schedule = toSchedule(response, [])
|
||||
|
||||
expect(summary.currentState?.type).toBe('waiting_before_next')
|
||||
expect(summary.currentState?.currentTransaction).toBe(2)
|
||||
expect(summary.currentState?.waitSeconds).toBe(120)
|
||||
expect(schedule.completed.length).toBe(1)
|
||||
expect(schedule.summary.derivedStatus).toEqual({
|
||||
value: 'waiting_before_next',
|
||||
terminated: false,
|
||||
})
|
||||
expect(schedule.active?.index).toBe(1)
|
||||
expect(schedule.active?.durationSeconds).toBe(42)
|
||||
})
|
||||
|
||||
it('falls back to frozen-utxo check when last schedule entry is stale', () => {
|
||||
const schedule: Schedule = [
|
||||
[0, 0, 8, 'INTERNAL', 10, 16, 1],
|
||||
[1, 0, 8, 'bc1qdestination', 0, 16, 0],
|
||||
]
|
||||
it('derives completed state', () => {
|
||||
const response: TumblerPlanResponse = {
|
||||
plan_id: 'any',
|
||||
wallet_name: 'wallet.jmdat',
|
||||
status: 'completed',
|
||||
destinations: ['final-destination'],
|
||||
current_phase: 4,
|
||||
phases: [
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 0,
|
||||
status: 'completed',
|
||||
wait_seconds: 33.060614864497204,
|
||||
started_at: '2009-01-03T10:37:22.070815+00:00',
|
||||
finished_at: '2009-01-03T10:38:23.012313+00:00',
|
||||
error: null,
|
||||
mixdepth: 2,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: '0c75c7593508f7a3535be252e06ac4274caaab3a26e44d170938a40c2057ba31',
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 1,
|
||||
status: 'completed',
|
||||
wait_seconds: 9.086152239558118,
|
||||
started_at: '2009-01-03T10:45:56.185757+00:00',
|
||||
finished_at: '2009-01-03T10:46:56.547960+00:00',
|
||||
error: null,
|
||||
mixdepth: 0,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: '91b49b8a01a68ffb1638ee52579b83bab29ac3035efc20c4eb90133355c072f4',
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 2,
|
||||
status: 'completed',
|
||||
wait_seconds: 7.629039450908496,
|
||||
started_at: '2009-01-03T10:47:35.670512+00:00',
|
||||
finished_at: '2009-01-03T10:48:33.074154+00:00',
|
||||
error: null,
|
||||
mixdepth: 1,
|
||||
amount: null,
|
||||
amount_fraction: 0.5535,
|
||||
counterparty_count: 1,
|
||||
destination: 'INTERNAL',
|
||||
txid: '556bafc2f01c363b5b40f0920a983d509860abc809562577c3cf45394ef705ca',
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
{
|
||||
kind: 'taker_coinjoin',
|
||||
index: 9,
|
||||
status: 'completed',
|
||||
wait_seconds: 0,
|
||||
started_at: '2009-01-03T11:13:31.800857+00:00',
|
||||
finished_at: '2009-01-03T11:14:35.691720+00:00',
|
||||
error: null,
|
||||
mixdepth: 4,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 1,
|
||||
destination: 'final-destination',
|
||||
txid: '1f95d0ff8b43645777b60bc23544b955be7e4d8984bb66856747f1fe5674df00',
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
},
|
||||
],
|
||||
created_at: '2009-01-03T10:37:19.808342+00:00',
|
||||
updated_at: '2009-01-03T11:14:35.709755+00:00',
|
||||
error: null,
|
||||
stale: false,
|
||||
}
|
||||
|
||||
expect(isScheduleLikelyCompletedSuccessfully(schedule, false)).toBe(false)
|
||||
expect(isScheduleLikelyCompletedSuccessfully(schedule, true)).toBe(true)
|
||||
const schedule = toSchedule(response, [])
|
||||
|
||||
expect(schedule.completed.length).toBe(4)
|
||||
expect(schedule.summary.derivedStatus).toEqual({
|
||||
value: 'completed',
|
||||
terminated: true,
|
||||
})
|
||||
expect(schedule.active).toBeUndefined()
|
||||
|
||||
expect(schedule.summary.externalDestinations).toEqual([
|
||||
{
|
||||
address: 'final-destination',
|
||||
transactionId: '1f95d0ff8b43645777b60bc23544b955be7e4d8984bb66856747f1fe5674df00',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,251 +1,325 @@
|
|||
import { isValidNumber } from '@/lib/utils'
|
||||
import type { TumblerPhaseResponse, TumblerPlanResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { JM_TUMBLER_MIN_CONFIRMATIONS_BETWEEN_PHASES } from '@/constants/jm'
|
||||
import type { Jar } from '@/context/JamWalletInfoContext'
|
||||
import { MEAN_DURATION_BETWEEN_BLOCKS_SECONDS, percentageToFactor } from '@/lib/utils'
|
||||
import type { TxId } from '@/store/jmTxStore'
|
||||
import type { BitcoinAddress, JarIndex, Minutes } from '@/types/global'
|
||||
import type { BitcoinAddress, Factor, JarIndex, Seconds } from '@/types/global'
|
||||
|
||||
type AmountFraction = number
|
||||
type AmountCounterparties = number
|
||||
type SchedulerDestinationAddress = 'INTERNAL' | BitcoinAddress
|
||||
type WaitTimeInMinutes = Minutes
|
||||
type Rounding = number
|
||||
type StateFlag = 0 | 1 | TxId
|
||||
type ScheduleEntryKind = 'taker_coinjoin' | 'maker_session' | TumblerPhaseResponse['kind']
|
||||
type ScheduleEntryStatus =
|
||||
'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'skipped' | TumblerPhaseResponse['status']
|
||||
type ScheduleStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | TumblerPlanResponse['status']
|
||||
|
||||
// [mixdepth, amount-fraction, N-counterparties (requested), destination address, wait time in minutes, rounding, flag indicating incomplete/broadcast/completed (0/txid/1)]
|
||||
// e.g.
|
||||
// - [ 2, 0.2456498211214867, 4, "INTERNAL", 0.01, 16, 1 ]
|
||||
// - [ 3, 0, 8, "bcrt1qpnv3nze7u6ecw63mn06ksxh497a3lryagh233q", 0.04, 16, 0 ]
|
||||
export type ScheduleEntry = [
|
||||
JarIndex,
|
||||
AmountFraction,
|
||||
AmountCounterparties,
|
||||
SchedulerDestinationAddress,
|
||||
WaitTimeInMinutes,
|
||||
Rounding,
|
||||
StateFlag,
|
||||
]
|
||||
export type TakerEntryDetails = {
|
||||
jarIndex: JarIndex
|
||||
jar?: Jar
|
||||
amountFraction?: number
|
||||
isSweep: boolean
|
||||
numberOfRequestedCounterparties: AmountCounterparties // N-counterparties (requested)
|
||||
} & (
|
||||
| {
|
||||
internal: true
|
||||
externalDestinationAddress: undefined
|
||||
}
|
||||
| {
|
||||
internal: false
|
||||
externalDestinationAddress: BitcoinAddress
|
||||
}
|
||||
)
|
||||
|
||||
export type Schedule = ScheduleEntry[]
|
||||
|
||||
export interface ScheduleProgressStep {
|
||||
widthPercent: number
|
||||
isComplete: boolean
|
||||
isActive: boolean
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
export type MakerEntryDetails = {
|
||||
durationSeconds: Seconds
|
||||
idleTimeoutSeconds: Seconds
|
||||
}
|
||||
|
||||
export type ScheduleEntryState = 'pending' | 'broadcasted' | 'confirmed'
|
||||
|
||||
export interface ScheduleProgressEntry {
|
||||
export type ScheduleEntry = {
|
||||
index: number
|
||||
waitBeforeNextSeconds: number
|
||||
state: ScheduleEntryState
|
||||
txid?: TxId
|
||||
isLast: boolean
|
||||
kind: ScheduleEntryKind
|
||||
startedAt?: Date
|
||||
finishedAt?: Date
|
||||
waitTimeInSeconds: Seconds
|
||||
status: {
|
||||
value: ScheduleEntryStatus
|
||||
pending: boolean
|
||||
running: boolean
|
||||
completed: boolean
|
||||
failed: boolean
|
||||
cancelled: boolean
|
||||
skipped: boolean
|
||||
}
|
||||
derivedStatus: {
|
||||
terminated: boolean
|
||||
}
|
||||
transactionId?: TxId
|
||||
__raw?: TumblerPhaseResponse // TODO: not optional
|
||||
} & Partial<TakerEntryDetails> &
|
||||
Partial<MakerEntryDetails>
|
||||
|
||||
export type ScheduleDerivedStatus = 'waiting_before_next' | 'waiting_for_confirmation'
|
||||
|
||||
type ExternalDestination = {
|
||||
address: BitcoinAddress
|
||||
transactionId?: TxId
|
||||
}
|
||||
|
||||
export type ScheduleCurrentStateType =
|
||||
'waiting_before_next' | 'creating_and_broadcasting' | 'waiting_for_confirmation' | 'transaction_confirmed'
|
||||
|
||||
export interface ScheduleCurrentState {
|
||||
type: ScheduleCurrentStateType
|
||||
currentTransaction: number
|
||||
totalTransactions: number
|
||||
waitSeconds?: number
|
||||
txid?: TxId
|
||||
export interface ScheduleSummary {
|
||||
status: {
|
||||
value: ScheduleStatus
|
||||
pending: boolean
|
||||
running: boolean
|
||||
completed: boolean
|
||||
failed: boolean
|
||||
cancelled: boolean
|
||||
}
|
||||
startedAt?: Date
|
||||
finishedAt?: Date
|
||||
derivedStatus: {
|
||||
value: ScheduleDerivedStatus | ScheduleStatus
|
||||
terminated: boolean
|
||||
}
|
||||
totalWaitDurationBetweenPhasesInSeconds: Seconds
|
||||
estimatedTotalDurationInSeconds: Seconds
|
||||
externalDestinations: ExternalDestination[]
|
||||
}
|
||||
|
||||
export interface ScheduleProgressSummary {
|
||||
totalWaitSeconds: number
|
||||
totalTransactions: number
|
||||
completedTransactions: number
|
||||
currentTransactionIndex: number
|
||||
isDone: boolean
|
||||
steps: ScheduleProgressStep[]
|
||||
entries: ScheduleProgressEntry[]
|
||||
currentState?: ScheduleCurrentState
|
||||
}
|
||||
|
||||
const MIN_STEP_WIDTH_PERCENT = 8
|
||||
|
||||
const toNumberOrDefault = (value: unknown, fallback: number): number => {
|
||||
return isValidNumber(value) ? value : fallback
|
||||
export interface ScheduleProgressStep {
|
||||
widthPercent: number
|
||||
completed: boolean
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const getScheduleEntryWaitMinutes = (entry: ScheduleEntry): number => {
|
||||
return Math.max(0, toNumberOrDefault(entry[4], 0))
|
||||
}
|
||||
|
||||
const getScheduleEntryState = (entry: ScheduleEntry): string | number => {
|
||||
return entry[6] ?? 0
|
||||
}
|
||||
|
||||
const getScheduleEntryTxId = (entry: ScheduleEntry): TxId | undefined => {
|
||||
const state = getScheduleEntryState(entry)
|
||||
return typeof state === 'string' ? state : undefined
|
||||
}
|
||||
|
||||
// Scheduler state flag convention from backend:
|
||||
// 0 => not yet broadcast, txid string => broadcasted (unconfirmed), 1 => confirmed.
|
||||
const toScheduleEntryState = (entry: ScheduleEntry): ScheduleEntryState => {
|
||||
const state = getScheduleEntryState(entry)
|
||||
|
||||
if (state === 1) {
|
||||
return 'confirmed'
|
||||
// TODO: refactor to object with summary props
|
||||
export type Schedule = {
|
||||
summary: ScheduleSummary
|
||||
progress: {
|
||||
steps: ScheduleProgressStep[]
|
||||
}
|
||||
if (typeof state === 'string') {
|
||||
return 'broadcasted'
|
||||
}
|
||||
return 'pending'
|
||||
active?: ScheduleEntry
|
||||
entries: ScheduleEntry[]
|
||||
// all pending entries
|
||||
pending: ScheduleEntry[]
|
||||
// all successfully completed entries
|
||||
completed: ScheduleEntry[]
|
||||
}
|
||||
|
||||
const isScheduleEntryConfirmed = (entry: ScheduleEntry): boolean => {
|
||||
return getScheduleEntryState(entry) === 1
|
||||
const FIRST_STEP_WIDTH_PERCENT = 5
|
||||
const MIN_STEP_WIDTH_PERCENT = 3
|
||||
|
||||
const MIN_ESTIMATE_ACTION_RUNTIME_IN_SECONDS: Seconds = 60
|
||||
|
||||
const TIME_ESTIMATE_FACTOR: Factor = 1.1
|
||||
const MEAN_CONFIMRATION_WAIT_TIME_BETWEEN_PHASES_SECONDS: Seconds =
|
||||
JM_TUMBLER_MIN_CONFIRMATIONS_BETWEEN_PHASES * MEAN_DURATION_BETWEEN_BLOCKS_SECONDS
|
||||
|
||||
export const isPlanTerminated = (plan: TumblerPlanResponse) => {
|
||||
return plan.stale === false && plan.status !== 'pending' && plan.status !== 'running'
|
||||
}
|
||||
|
||||
export const isScheduleEntrySuccessful = (entry: ScheduleEntry): boolean => {
|
||||
const state = getScheduleEntryState(entry)
|
||||
return state === 1 || typeof state === 'string'
|
||||
}
|
||||
export const toSchedule = (plan: TumblerPlanResponse, jars: Jar[]): Schedule => {
|
||||
const entries = plan.phases.map((it) => toScheduleEntry(it, jars))
|
||||
const pending = entries.filter((it) => it.status.pending === true)
|
||||
const completed = entries.filter((it) => it.status.completed === true)
|
||||
|
||||
export const isScheduleValue = (value: unknown): value is Schedule => {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(entry) =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 7 &&
|
||||
typeof entry[0] === 'number' /* JarIndex */ &&
|
||||
typeof entry[1] === 'number' /* AmountFraction */ &&
|
||||
typeof entry[2] === 'number' /* AmountCounterparties */ &&
|
||||
typeof entry[3] === 'string' /* SchedulerDestinationAddress */ &&
|
||||
typeof entry[4] === 'number' /* WaitTimeInMinutes */ &&
|
||||
typeof entry[5] === 'number' /* Rounding */ &&
|
||||
(typeof entry[6] === 'number' || typeof entry[6] === 'string') /* StateFlag */,
|
||||
)
|
||||
const totalWaitDurationBetweenPhasesInSeconds: Seconds = Math.ceil(
|
||||
Math.max(
|
||||
1,
|
||||
entries.slice(0, Math.max(0, entries.length - 1)).reduce((acc, entry) => {
|
||||
return acc + entry.waitTimeInSeconds
|
||||
}, 0),
|
||||
),
|
||||
)
|
||||
|
||||
const totalEstimatedDurationBetweenPhasesInSeconds: Seconds = Math.ceil(
|
||||
Math.max(
|
||||
1,
|
||||
entries.slice(0, Math.max(0, entries.length - 1)).reduce((acc, entry) => {
|
||||
const fixedRuntimeInSeconds = Math.max(
|
||||
MIN_ESTIMATE_ACTION_RUNTIME_IN_SECONDS,
|
||||
Math.min(entry.durationSeconds ?? 0, entry.idleTimeoutSeconds ?? 0),
|
||||
)
|
||||
const waitTime = Math.max(entry.waitTimeInSeconds, MEAN_CONFIMRATION_WAIT_TIME_BETWEEN_PHASES_SECONDS)
|
||||
const estimatedActionRuntimeInSeconds = fixedRuntimeInSeconds + waitTime
|
||||
return acc + estimatedActionRuntimeInSeconds
|
||||
}, 0),
|
||||
),
|
||||
)
|
||||
|
||||
const estimatedTotalDurationInSeconds: Seconds = Math.ceil(
|
||||
TIME_ESTIMATE_FACTOR * totalEstimatedDurationBetweenPhasesInSeconds,
|
||||
)
|
||||
|
||||
const externalDestinations: ExternalDestination[] = entries
|
||||
.filter((it) => it.externalDestinationAddress !== undefined)
|
||||
.map((it) => ({
|
||||
address: it.externalDestinationAddress!,
|
||||
transactionId: it.transactionId,
|
||||
}))
|
||||
|
||||
const status: ScheduleSummary['status'] = {
|
||||
value: plan.status,
|
||||
pending: plan.status === 'pending',
|
||||
running: plan.status === 'running',
|
||||
completed: plan.status === 'completed',
|
||||
failed: plan.status === 'failed',
|
||||
cancelled: plan.status === 'cancelled',
|
||||
}
|
||||
|
||||
const active = !status.running ? undefined : entries.find((it) => it.index === plan.current_phase)
|
||||
|
||||
const startedAt = entries.at(0)?.startedAt
|
||||
const finishedAt = entries.at(-1)?.finishedAt
|
||||
const summary: ScheduleSummary = {
|
||||
status,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
derivedStatus: {
|
||||
value: toScheduleDerivedStatus(entries, active) ?? plan.status,
|
||||
terminated: isPlanTerminated(plan),
|
||||
},
|
||||
totalWaitDurationBetweenPhasesInSeconds,
|
||||
estimatedTotalDurationInSeconds,
|
||||
externalDestinations,
|
||||
}
|
||||
|
||||
const progress = toScheduleProgressSummary(summary, entries, active)
|
||||
|
||||
return {
|
||||
summary,
|
||||
progress,
|
||||
active,
|
||||
completed,
|
||||
pending,
|
||||
entries,
|
||||
}
|
||||
}
|
||||
|
||||
export const toScheduleProgressSummary = (schedule: Schedule): ScheduleProgressSummary => {
|
||||
if (schedule.length === 0) {
|
||||
return {
|
||||
totalWaitSeconds: 0,
|
||||
totalTransactions: 0,
|
||||
completedTransactions: 0,
|
||||
currentTransactionIndex: 0,
|
||||
isDone: true,
|
||||
steps: [],
|
||||
entries: [],
|
||||
export const toScheduleEntry = (phase: TumblerPhaseResponse, jars: Jar[]): ScheduleEntry => {
|
||||
const status: ScheduleEntry['status'] = {
|
||||
value: phase.status,
|
||||
pending: phase.status === 'pending',
|
||||
completed: phase.status === 'completed',
|
||||
running: phase.status === 'running',
|
||||
failed: phase.status === 'failed',
|
||||
cancelled: phase.status === 'cancelled',
|
||||
skipped: phase.status === 'skipped',
|
||||
}
|
||||
let value: ScheduleEntry = {
|
||||
index: phase.index,
|
||||
kind: phase.kind,
|
||||
status,
|
||||
derivedStatus: {
|
||||
terminated: !status.pending && !status.running,
|
||||
},
|
||||
startedAt: phase.started_at ? new Date(Date.parse(phase.started_at)) : undefined,
|
||||
finishedAt: phase.finished_at ? new Date(Date.parse(phase.finished_at)) : undefined,
|
||||
waitTimeInSeconds: phase.wait_seconds ?? 0,
|
||||
transactionId: phase.txid ?? undefined,
|
||||
__raw: phase,
|
||||
}
|
||||
|
||||
if (phase.kind === 'taker_coinjoin') {
|
||||
const internal = phase.destination?.toUpperCase() === 'INTERNAL'
|
||||
const details: TakerEntryDetails = {
|
||||
jarIndex: phase.mixdepth ?? -1,
|
||||
jar: jars.find((it) => it.jarIndex === phase.mixdepth),
|
||||
isSweep: phase.amount === 0,
|
||||
amountFraction: phase.amount_fraction ?? undefined,
|
||||
numberOfRequestedCounterparties: phase.counterparty_count ?? 0,
|
||||
...(internal === true
|
||||
? {
|
||||
internal: true,
|
||||
externalDestinationAddress: undefined,
|
||||
}
|
||||
: {
|
||||
internal: false,
|
||||
externalDestinationAddress: phase.destination!,
|
||||
}),
|
||||
}
|
||||
value = { ...value, ...details }
|
||||
}
|
||||
if (phase.kind === 'maker_session') {
|
||||
const details: MakerEntryDetails = {
|
||||
durationSeconds: phase.duration_seconds ?? 0,
|
||||
idleTimeoutSeconds: phase.idle_timeout_seconds ?? 0,
|
||||
}
|
||||
value = { ...value, ...details }
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const completedTransactions = schedule.reduce((acc, entry) => {
|
||||
return acc + (isScheduleEntryConfirmed(entry) ? 1 : 0)
|
||||
}, 0)
|
||||
|
||||
const totalWaitSeconds = Math.max(
|
||||
1,
|
||||
schedule.slice(0, Math.max(0, schedule.length - 1)).reduce((acc, entry) => {
|
||||
return acc + getScheduleEntryWaitMinutes(entry) * 60
|
||||
}, 0),
|
||||
const toScheduleProgressSummary = (
|
||||
summary: ScheduleSummary,
|
||||
entries: ScheduleEntry[],
|
||||
activeEntry: ScheduleEntry | undefined,
|
||||
): ScheduleProgressSummary => {
|
||||
const entriesByIndex = entries.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.index] = item
|
||||
return acc
|
||||
},
|
||||
{} as { [key: number]: ScheduleEntry },
|
||||
)
|
||||
|
||||
const isDone = completedTransactions >= schedule.length
|
||||
const currentTransactionIndex = Math.min(completedTransactions, schedule.length - 1)
|
||||
const remainder = Math.max(
|
||||
1,
|
||||
(1 - percentageToFactor(FIRST_STEP_WIDTH_PERCENT)) * summary.totalWaitDurationBetweenPhasesInSeconds,
|
||||
)
|
||||
|
||||
const steps: ScheduleProgressStep[] = schedule.map((_entry, index) => {
|
||||
const steps: ScheduleProgressStep[] = Object.values(entriesByIndex).map((entry) => {
|
||||
const previousEntry: ScheduleEntry | undefined = entriesByIndex[entry.index - 1]
|
||||
const widthPercent =
|
||||
index === 0
|
||||
? MIN_STEP_WIDTH_PERCENT
|
||||
: Math.max(
|
||||
MIN_STEP_WIDTH_PERCENT,
|
||||
((Math.max(1, getScheduleEntryWaitMinutes(schedule[index - 1])) * 60) / totalWaitSeconds) * 100,
|
||||
)
|
||||
entry.index === 0
|
||||
? FIRST_STEP_WIDTH_PERCENT
|
||||
: Math.max(MIN_STEP_WIDTH_PERCENT, ((previousEntry?.waitTimeInSeconds ?? 0) / remainder) * 100)
|
||||
|
||||
return {
|
||||
widthPercent,
|
||||
isComplete: completedTransactions > index,
|
||||
isActive: !isDone && completedTransactions === index,
|
||||
isFirst: index === 0,
|
||||
isLast: index === schedule.length - 1,
|
||||
completed: entry.status.completed === true,
|
||||
active: entry === activeEntry,
|
||||
}
|
||||
})
|
||||
|
||||
const entries: ScheduleProgressEntry[] = schedule.map((entry, index) => {
|
||||
return {
|
||||
index,
|
||||
waitBeforeNextSeconds: index >= schedule.length - 1 ? 0 : getScheduleEntryWaitMinutes(entry) * 60,
|
||||
state: toScheduleEntryState(entry),
|
||||
txid: getScheduleEntryTxId(entry),
|
||||
isLast: index === schedule.length - 1,
|
||||
}
|
||||
})
|
||||
|
||||
let currentState: ScheduleCurrentState | undefined
|
||||
if (!isDone) {
|
||||
const activeEntry = schedule[currentTransactionIndex]
|
||||
const activeEntryTxId = getScheduleEntryTxId(activeEntry)
|
||||
const activeEntryState = getScheduleEntryState(activeEntry)
|
||||
|
||||
// Infer the user-facing phase from the currently active entry and the previous wait slot.
|
||||
if (activeEntryTxId !== undefined) {
|
||||
currentState = {
|
||||
type: 'waiting_for_confirmation',
|
||||
currentTransaction: currentTransactionIndex + 1,
|
||||
totalTransactions: schedule.length,
|
||||
txid: activeEntryTxId,
|
||||
}
|
||||
} else if (activeEntryState === 1) {
|
||||
currentState = {
|
||||
type: 'transaction_confirmed',
|
||||
currentTransaction: currentTransactionIndex + 1,
|
||||
totalTransactions: schedule.length,
|
||||
}
|
||||
} else {
|
||||
const waitSeconds =
|
||||
currentTransactionIndex > 0
|
||||
? Math.ceil(getScheduleEntryWaitMinutes(schedule[currentTransactionIndex - 1]) * 60)
|
||||
: 0
|
||||
|
||||
currentState =
|
||||
waitSeconds > 0
|
||||
? {
|
||||
type: 'waiting_before_next',
|
||||
currentTransaction: currentTransactionIndex + 1,
|
||||
totalTransactions: schedule.length,
|
||||
waitSeconds,
|
||||
}
|
||||
: {
|
||||
type: 'creating_and_broadcasting',
|
||||
currentTransaction: currentTransactionIndex + 1,
|
||||
totalTransactions: schedule.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalWaitSeconds,
|
||||
totalTransactions: schedule.length,
|
||||
completedTransactions: Math.min(completedTransactions, schedule.length),
|
||||
currentTransactionIndex,
|
||||
isDone,
|
||||
steps,
|
||||
entries,
|
||||
currentState,
|
||||
}
|
||||
}
|
||||
|
||||
export const isScheduleLikelyCompletedSuccessfully = (schedule: Schedule, allUtxosFrozen: boolean): boolean => {
|
||||
if (schedule.length === 0) {
|
||||
return false
|
||||
const toScheduleDerivedStatus = (
|
||||
entries: ScheduleEntry[],
|
||||
activeEntry: ScheduleEntry | undefined,
|
||||
): ScheduleDerivedStatus | undefined => {
|
||||
// Infer the user-facing phase from the currently active entry and the previous wait slot.
|
||||
if (activeEntry?.transactionId !== undefined) {
|
||||
return 'waiting_for_confirmation'
|
||||
}
|
||||
|
||||
const entriesBeforeLastSucceeded = schedule.slice(0, -1).every((entry) => isScheduleEntrySuccessful(entry))
|
||||
const lastEntry = schedule.at(-1)
|
||||
if (lastEntry === undefined) {
|
||||
return false
|
||||
const previousEntry = activeEntry && entries.find((it) => it.index === activeEntry.index - 1)
|
||||
if (previousEntry?.status.completed === true && activeEntry?.startedAt === undefined) {
|
||||
return 'waiting_before_next'
|
||||
}
|
||||
|
||||
const lastEntrySucceeded = isScheduleEntrySuccessful(lastEntry)
|
||||
|
||||
return entriesBeforeLastSucceeded && (lastEntrySucceeded || allUtxosFrozen)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// TODO: move to utils and use Intl.DurationFormat (es2025) if available
|
||||
export const formatDuration = (seconds: number, t: TFunction): string => {
|
||||
const roundedSeconds = Math.max(0, Math.ceil(seconds))
|
||||
const minutes = Math.floor(roundedSeconds / 60)
|
||||
const remainingSeconds = roundedSeconds % 60
|
||||
|
||||
if (minutes === 0) {
|
||||
return t('global.duration_seconds', { seconds: remainingSeconds.toLocaleString() })
|
||||
}
|
||||
if (remainingSeconds === 0) {
|
||||
return t('global.duration_minutes', { minutes: minutes.toLocaleString() })
|
||||
}
|
||||
return t('global.duration_minutes_seconds', {
|
||||
minutes: minutes.toLocaleString(),
|
||||
seconds: remainingSeconds.toLocaleString(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import type { JarIndex } from '@/types/global'
|
||||
|
||||
const badgeVariants = cva(
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
|
|
@ -18,6 +18,10 @@ const badgeVariants = cva(
|
|||
jar3: 'border-transparent bg-jar3 text-primary-foreground [a&]:hover:bg-jar3/90',
|
||||
jar4: 'border-transparent bg-jar4 text-primary-foreground [a&]:hover:bg-jar4/90',
|
||||
jarUnknown: 'border-transparent bg-jar-unknown text-primary-foreground [a&]:hover:bg-jar-unknown/90',
|
||||
muted: 'border-transparent bg-muted text-muted-foreground [a&]:hover:bg-muted/90',
|
||||
success: 'border-transparent bg-brand-success text-brand-success-foreground [a&]:hover:bg-brand-success/90',
|
||||
warning: 'border-transparent bg-brand-warning text-brand-warning-foreground [a&]:hover:bg-brand-warning/90',
|
||||
info: 'border-transparent bg-brand-info text-brand-info-foreground [a&]:hover:bg-brand-info/90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -26,7 +30,7 @@ const badgeVariants = cva(
|
|||
},
|
||||
)
|
||||
|
||||
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
|
||||
export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
|
||||
|
||||
const JAR_BADGE_VARIANTS: Record<JarIndex, BadgeVariant> = {
|
||||
0: 'jar0',
|
||||
|
|
@ -36,7 +40,5 @@ const JAR_BADGE_VARIANTS: Record<JarIndex, BadgeVariant> = {
|
|||
4: 'jar4',
|
||||
}
|
||||
|
||||
const jarBadgeVariant = (jarIndex?: JarIndex): BadgeVariant | undefined =>
|
||||
export const jarBadgeVariant = (jarIndex?: JarIndex): BadgeVariant | undefined =>
|
||||
jarIndex === undefined ? undefined : (JAR_BADGE_VARIANTS[jarIndex] ?? 'jarUnknown')
|
||||
|
||||
export { badgeVariants, jarBadgeVariant }
|
||||
|
|
|
|||
157
src/components/ui/item.tsx
Normal file
157
src/components/ui/item.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import * as React from 'react'
|
||||
import * as Slot from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div role="list" data-slot="item-group" className={cn('group/item-group flex flex-col', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||
return <Separator data-slot="item-separator" orientation="horizontal" className={cn('my-0', className)} {...props} />
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
'group/item flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border-border',
|
||||
muted: 'bg-muted/50',
|
||||
},
|
||||
size: {
|
||||
default: 'gap-4 p-4',
|
||||
sm: 'gap-2.5 px-4 py-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : 'div'
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "size-8 rounded-sm border bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image: 'size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="item-actions" className={cn('flex items-center gap-2', className)} {...props} />
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn('flex basis-full items-center justify-between gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn('flex basis-full items-center justify-between gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
53
src/components/ui/slider.tsx
Normal file
53
src/components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
|
||||
[value, defaultValue, min, max],
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn('bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full')}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import { percentageToFactor, parseSemanticVersion } from '@/lib/utils'
|
||||
import type { AmountSats, Milliseconds } from '@/types/global'
|
||||
import type { AmountSats, Milliseconds, Seconds } from '@/types/global'
|
||||
import { version as packageInfoVersion } from '../../package.json'
|
||||
import { JM_API_AUTH_TOKEN_EXPIRY, JM_DUST_THRESHOLD, JM_WALLET_FILE_EXTENSION } from './jm'
|
||||
import {
|
||||
JM_API_AUTH_TOKEN_EXPIRY,
|
||||
JM_DUST_THRESHOLD,
|
||||
JM_MINIMUM_MAKERS_DEFAULT,
|
||||
JM_NG_DEFAULT_TUMBLER_PARAMS,
|
||||
JM_WALLET_FILE_EXTENSION,
|
||||
} from './jm'
|
||||
import { parseAsIntOrDefault } from './meta-env-utils'
|
||||
|
||||
export const APP_DISPLAY_VERSION = (() => {
|
||||
|
|
@ -102,6 +108,65 @@ export const JAM_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX: Milliseconds = Math.max(
|
|||
60_000,
|
||||
)
|
||||
|
||||
export const JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS: Seconds = Math.max(
|
||||
parseAsIntOrDefault(import.meta.env.VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS, 60 * 60),
|
||||
1,
|
||||
)
|
||||
const JAM_SWEEP_MAKER_SESSION_IDLE_DEFAULT_TIMEOUT_SECONDS: Seconds =
|
||||
JM_NG_DEFAULT_TUMBLER_PARAMS.maker_session_seconds - 15 * 60
|
||||
export const JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS: Seconds = Math.max(
|
||||
parseAsIntOrDefault(
|
||||
import.meta.env.VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS,
|
||||
JAM_SWEEP_MAKER_SESSION_IDLE_DEFAULT_TIMEOUT_SECONDS,
|
||||
),
|
||||
JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
export const JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT: number = Math.max(
|
||||
parseAsIntOrDefault(import.meta.env.VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT, 3),
|
||||
1,
|
||||
)
|
||||
export const JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT: Seconds = Math.max(
|
||||
parseAsIntOrDefault(import.meta.env.VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT, 3),
|
||||
JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT,
|
||||
)
|
||||
|
||||
// see https://github.com/joinmarket-ng/joinmarket-ng/blob/0.34.2/tumbler/src/tumbler/plan.py#L176 (last checked 2026-07-22)
|
||||
export const JAM_SWEEP_MAX_MIN_NUMBER_OF_COLLABORATORS = 20
|
||||
export const JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS = Math.max(
|
||||
Math.min(
|
||||
parseAsIntOrDefault(import.meta.env.VITE_JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS, JM_MINIMUM_MAKERS_DEFAULT),
|
||||
JAM_SWEEP_MAX_MIN_NUMBER_OF_COLLABORATORS,
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
// see https://github.com/joinmarket-ng/joinmarket-ng/blob/0.34.2/tumbler/src/tumbler/plan.py#L177 (last checked 2026-07-22)
|
||||
export const JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS = JAM_SWEEP_MAX_MIN_NUMBER_OF_COLLABORATORS
|
||||
export const JAM_SWEEP_MIN_MAX_NUMBER_OF_COLLABORATORS = JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS
|
||||
|
||||
export const JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR = 2
|
||||
export const JAM_SWEEP_MAX_TRANSACTIONS_PER_JAR = 8
|
||||
|
||||
export const JAM_SWEEP_DEFAULT_MIN_ROUNDING_CHANCE_PERCENT: number = 0
|
||||
export const JAM_SWEEP_DEFAULT_MAX_ROUNDING_CHANCE_PERCENT: number = 100
|
||||
|
||||
export const JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT: number = Math.max(
|
||||
parseAsIntOrDefault(
|
||||
import.meta.env.VITE_JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT,
|
||||
JAM_SWEEP_DEFAULT_MIN_ROUNDING_CHANCE_PERCENT,
|
||||
),
|
||||
JAM_SWEEP_DEFAULT_MIN_ROUNDING_CHANCE_PERCENT,
|
||||
)
|
||||
|
||||
export const JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT = Math.max(
|
||||
parseAsIntOrDefault(
|
||||
import.meta.env.VITE_JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT,
|
||||
JAM_SWEEP_DEFAULT_MAX_ROUNDING_CHANCE_PERCENT,
|
||||
),
|
||||
JAM_SWEEP_DEFAULT_MIN_ROUNDING_CHANCE_PERCENT,
|
||||
)
|
||||
|
||||
/**
|
||||
* A gaplimit threshold at which a warning is displayed that with the given value a
|
||||
* decline in performance is to be expected. Importing 500 addresses (per jar!) leads to
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AmountSats, Milliseconds } from '@/types/global'
|
||||
import type { AmountSats, Factor, Milliseconds, Seconds } from '@/types/global'
|
||||
import { parseAsIntOrDefault } from './meta-env-utils'
|
||||
|
||||
export const JM_WALLET_FILE_EXTENSION = '.jmdat'
|
||||
|
|
@ -35,6 +35,14 @@ export const JM_TX_FEES_FACTOR_DEFAULT = 0.2
|
|||
|
||||
export const JM_GAPLIMIT_DEFAULT = 6
|
||||
|
||||
// initial value for `TAKER__UTXO_AGE` (last checked on 2026-07-19)
|
||||
// https://github.com/joinmarket-ng/joinmarket-ng/blob/0.34.1/jmcore/src/jmcore/settings.py#L877C5-L877C19
|
||||
export const JM_TAKER_UTXO_AGE = 5
|
||||
|
||||
// initial value for `TUMBLER__MIN_CONFIRMATIONS_BETWEEN_PHASES` (last checked on 2026-07-19)
|
||||
// https://github.com/joinmarket-ng/joinmarket-ng/blob/0.34.1/jmcore/src/jmcore/settings.py#L1031
|
||||
export const JM_TUMBLER_MIN_CONFIRMATIONS_BETWEEN_PHASES = 6
|
||||
|
||||
export const JM_GAPLIMIT_CONFIGKEY: ConfigKey = {
|
||||
section: 'POLICY',
|
||||
field: 'gaplimit',
|
||||
|
|
@ -66,3 +74,52 @@ export const FEE_CONFIG_KEYS: Record<FeeConfigName, ConfigKey> = {
|
|||
max_cj_fee_rel: { section: 'POLICY', field: 'max_cj_fee_rel' },
|
||||
max_sweep_fee_change: { section: 'POLICY', field: 'max_sweep_fee_change' },
|
||||
}
|
||||
|
||||
// https://github.com/joinmarket-ng/joinmarket-ng/blob/0.33.0/tumbler/src/tumbler/builder.py#L49 (last checked: 2026-07-14)
|
||||
export interface TumblerParameters {
|
||||
maker_count_min: number
|
||||
maker_count_max: number
|
||||
// Average wait between phases (mean of an exponential distribution)
|
||||
time_lambda_seconds: Seconds
|
||||
// Multiplier on ``time_lambda_seconds`` for stage-1 (cleavage) sweeps
|
||||
stage1_wait_multiplier: Factor
|
||||
include_maker_sessions: boolean
|
||||
mincjamount_sats: AmountSats
|
||||
maker_session_seconds: Seconds
|
||||
/**
|
||||
* If set, maker phases exit successfully when no CoinJoin has been served
|
||||
* within this many seconds. Useful as a safety fallback when the wallet is
|
||||
* never selected as a counterparty.
|
||||
*/
|
||||
maker_session_idle_timeout_seconds: Seconds | undefined
|
||||
// Minimum number of destination-bearing taker CJs per mixdepth (excluding sweep).
|
||||
mintxcount: number
|
||||
// Maximum re-tries per failed taker CoinJoin phase before the plan fails
|
||||
max_phase_retries: number
|
||||
// Probability that any given non-sweep taker CJ amount is rounded to a random number of significant figures. Set to 0.0 to disable rounding entirely.
|
||||
rounding_chance: Factor
|
||||
}
|
||||
|
||||
export const JM_NG_DEFAULT_TUMBLER_PARAMS: TumblerParameters = {
|
||||
maker_count_min: 5, // default: 5
|
||||
maker_count_max: 9, // default: 9
|
||||
// Average wait between phases (mean of an exponential distribution)
|
||||
time_lambda_seconds: 6 * 60 * 60, // default: 6 hours
|
||||
// Multiplier on ``time_lambda_seconds`` for stage-1 (cleavage) sweeps
|
||||
stage1_wait_multiplier: 3, // default: 3.0
|
||||
include_maker_sessions: true, // default: true
|
||||
mincjamount_sats: 100_000, // default: 100_000
|
||||
maker_session_seconds: 12 * 60 * 60, // default: 12.0 * 60.0 * 60.0
|
||||
/**
|
||||
* If set, maker phases exit successfully when no CoinJoin has been served
|
||||
* within this many seconds. Useful as a safety fallback when the wallet is
|
||||
* never selected as a counterparty.
|
||||
*/
|
||||
maker_session_idle_timeout_seconds: undefined, // default: undefined
|
||||
// Minimum number of destination-bearing taker CJs per mixdepth (excluding sweep).
|
||||
mintxcount: 2, // default: 2
|
||||
// Maximum re-tries per failed taker CoinJoin phase before the plan fails
|
||||
max_phase_retries: 3, // default: 3
|
||||
// Probability that any given non-sweep taker CJ amount is rounded to a random number of significant figures. Set to 0.0 to disable rounding entirely.
|
||||
rounding_chance: 0.25, // default: 0.25
|
||||
}
|
||||
|
|
|
|||
25
src/constants/meta-env-utils.test.ts
Normal file
25
src/constants/meta-env-utils.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { parseAsIntOrDefault } from './meta-env-utils'
|
||||
|
||||
describe('parseAsIntOrDefault', () => {
|
||||
it('should parse input as int or default', () => {
|
||||
expect(parseAsIntOrDefault(0, 21)).toBe(0)
|
||||
expect(parseAsIntOrDefault(-1, 21)).toBe(-1)
|
||||
expect(parseAsIntOrDefault(1, 21)).toBe(1)
|
||||
expect(parseAsIntOrDefault('-1', 21)).toBe(-1)
|
||||
expect(parseAsIntOrDefault('0', 21)).toBe(0)
|
||||
expect(parseAsIntOrDefault('1', 21)).toBe(1)
|
||||
expect(parseAsIntOrDefault('1.12231', 21)).toBe(1)
|
||||
expect(parseAsIntOrDefault(1.12231, 21)).toBe(1)
|
||||
expect(parseAsIntOrDefault(9.99999, 21)).toBe(9)
|
||||
expect(parseAsIntOrDefault(Number.MAX_VALUE /* '1.7976931348623157e+308' */, 21)).toBe(1)
|
||||
expect(parseAsIntOrDefault(Number.MIN_VALUE /* '5e-324' */, 21)).toBe(5)
|
||||
|
||||
expect(parseAsIntOrDefault(undefined, 21)).toBe(21)
|
||||
expect(parseAsIntOrDefault('', 21)).toBe(21)
|
||||
expect(parseAsIntOrDefault(null, 21)).toBe(21)
|
||||
expect(parseAsIntOrDefault(String(Number.NaN), 21)).toBe(21)
|
||||
expect(parseAsIntOrDefault(Number.MAX_SAFE_INTEGER + 1, 21)).toBe(21)
|
||||
expect(parseAsIntOrDefault(Number.MIN_SAFE_INTEGER - 1, 21)).toBe(21)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- okay when trying to parse arbitrary value on purpose
|
||||
export const parseAsIntOrDefault = (value: any, defaultValue: number) => {
|
||||
const parsed = Number.parseInt(`${value ?? defaultValue}`, 10)
|
||||
export const parseAsIntOrDefault = (value: any, defaultValue: number): number => {
|
||||
const parsed = Number.parseInt(`${value ?? Number.NaN}`, 10)
|
||||
return Number.isSafeInteger(parsed) ? parsed : defaultValue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,20 @@ export interface PaymentAttempt {
|
|||
export interface TakerInfo {
|
||||
currentPaymentAttempt?: PaymentAttempt
|
||||
running: boolean
|
||||
scheduler: {
|
||||
running: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface MakerInfo {
|
||||
running: boolean
|
||||
}
|
||||
|
||||
interface JamSessionInfoContextType {
|
||||
blockHeight?: number
|
||||
takerInfo: TakerInfo
|
||||
rescanInfo: RescanInfo
|
||||
makerInfo: MakerInfo
|
||||
setRescanInfo: Dispatch<SetStateAction<RescanInfo>>
|
||||
setCurrentPaymentAttempt: (val: PaymentAttempt) => void
|
||||
clearCurrentPaymentAttempt: () => void
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { withQueryDelay } from '@/lib/queryClient'
|
|||
import { factorToPercentage, type WalletFileName } from '@/lib/utils'
|
||||
import { jmSessionStore } from '@/store/jmSessionStore'
|
||||
import { JamSessionInfoContext } from './JamSessionInfoContext'
|
||||
import type { PaymentAttempt, RescanInfo, TakerInfo } from './JamSessionInfoContext'
|
||||
import type { MakerInfo, PaymentAttempt, RescanInfo, TakerInfo } from './JamSessionInfoContext'
|
||||
|
||||
interface PaymentAttemptStoreState {
|
||||
state?: PaymentAttempt
|
||||
|
|
@ -57,11 +57,22 @@ export const JamSessionInfoContextProvider = ({
|
|||
const isWalletPayment = currentPaymentAttempt?.walletFileName === walletFileName
|
||||
const isCoinJoin = currentPaymentAttempt?.data.isCoinJoin === true
|
||||
const takerIsRunning = state?.coinjoin_in_process === true
|
||||
const schedulerIsRunning = takerIsRunning && !!state?.schedule
|
||||
return {
|
||||
currentPaymentAttempt: isWalletPayment && isCoinJoin ? currentPaymentAttempt : undefined,
|
||||
running: takerIsRunning,
|
||||
scheduler: {
|
||||
running: schedulerIsRunning,
|
||||
},
|
||||
}
|
||||
}, [state?.coinjoin_in_process, walletFileName, currentPaymentAttempt])
|
||||
}, [state?.coinjoin_in_process, state?.schedule, walletFileName, currentPaymentAttempt])
|
||||
|
||||
const makerInfo = useMemo<MakerInfo>(() => {
|
||||
const makerIsRunning = state?.maker_running === true
|
||||
return {
|
||||
running: makerIsRunning,
|
||||
}
|
||||
}, [state?.maker_running])
|
||||
|
||||
const getrescaninfoQueryOptions = useMemo(
|
||||
() =>
|
||||
|
|
@ -105,6 +116,7 @@ export const JamSessionInfoContextProvider = ({
|
|||
blockHeight: state?.block_height ?? undefined,
|
||||
takerRunning: state?.coinjoin_in_process === true,
|
||||
takerInfo,
|
||||
makerInfo,
|
||||
rescanInfo,
|
||||
setRescanInfo,
|
||||
setCurrentPaymentAttempt,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ export type FidelityBondSummary = {
|
|||
|
||||
export type WalletBalanceSummary = BalanceSummary
|
||||
|
||||
export type AddressStaticStatus = 'new' | 'used' | 'reused' | 'cj-out' | 'change-out' | 'non-cj-change' | 'deposit'
|
||||
export type AddressStaticStatus =
|
||||
'new' | 'reserved' | 'used' | 'reused' | 'cj-out' | 'change-out' | 'non-cj-change' | 'deposit'
|
||||
|
||||
type AddressStatusDynamic = string
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const toAddressSummary = (accountSummary: AccountSummary): AddressSummary => {
|
|||
const meta: AddressMeta = {
|
||||
jarIndex: account.jarIndex,
|
||||
address: entry.address,
|
||||
used: entry.status !== 'new',
|
||||
used: entry.status !== 'new' && entry.status !== 'reserved',
|
||||
status: entry.status,
|
||||
info: {
|
||||
bech32: info.bech32,
|
||||
|
|
|
|||
|
|
@ -493,8 +493,8 @@
|
|||
"scheduler": {
|
||||
"title": "Geplanter Sweep (experimentell)",
|
||||
"subtitle": "Führe mehrere Transaktionen mit zufälligen Beträgen und Zeitintervallen durch, um deine Privatsphäre und die von anderen zu erhöhen. Jede durchgeführte Transaktion ist ein Coinjoin. ",
|
||||
"error_starting_schedule_failed": "Der Scheduler konnte nicht gestartet werden. ",
|
||||
"error_stopping_schedule_failed": "Der Scheduler konnte nicht gestoppt werden. ",
|
||||
"error_starting_schedule_failed": "Der Scheduler konnte nicht gestartet werden. Begründung: {{ reason }}",
|
||||
"error_stopping_schedule_failed": "Der Scheduler konnte nicht gestoppt werden. Begründung: {{ reason }}",
|
||||
"complete_wallet_title": "Gesamte Wallet ",
|
||||
"complete_wallet_subtitle": "Das wird alle deine nicht eingefrorenen Gelder verwenden. ",
|
||||
"description_destination_addresses": "Der Scheduler wird alle verfügbaren Gelder in zufällige Beträge aufteilen und sie an mehrere Adressen schicken. ",
|
||||
|
|
@ -508,7 +508,7 @@
|
|||
"progress_tldr_seconds": "<1>{{ length }}</1> Transaktionen in <3>{{ seconds }}</3> Sekunden geplant.",
|
||||
"progress_tldr_hours": "<1>{{ length }}</1> Transaktionen in <3>{{ hours }}</3> Stunden geplant.",
|
||||
"progress_description": "Die Schätzung ist als minimale Wartezeit zu verstehen. Verzögerungen aufgrund von Netzwerkfehlern oder Transaktionsbestätigungen sind nicht eingerechnet. ",
|
||||
"progress_done": "Alle Transaktionen erfolgreich durchgeführt. Der Scheduler wird in Kürze stoppen. ",
|
||||
"progress_done_awaiting_stop": "Alle Transaktionen erfolgreich durchgeführt. Der Scheduler wird in Kürze stoppen. ",
|
||||
"precondition": {
|
||||
"hint_missing_utxos": "Um den Scheduler zu starten, brauchst du UTXOs mit {{ minConfirmations }} oder mehr Bestätigungen. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
|
||||
"hint_missing_confirmations": "Um den Scheduler zu starten, brauchen deine UTXOs {{ minConfirmations }} oder mehr Bestätigungen. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"duration_seconds": "{{ seconds }}s",
|
||||
"duration_minutes": "{{ minutes }}m",
|
||||
"duration_minutes_seconds": "{{ minutes }}m {{ seconds }}s",
|
||||
"errors": {
|
||||
"error_loading_wallet_failed": "Loading wallet failed. {{ reason }}",
|
||||
"error_reloading_wallet_failed": "Reloading wallet failed. {{ reason }}",
|
||||
|
|
@ -769,48 +772,46 @@
|
|||
"scheduler": {
|
||||
"title": "Scheduled Sweep (Experimental)",
|
||||
"subtitle": "Execute multiple transactions using random amounts and time intervals to increase the privacy of yourself and others. Every scheduled transaction is a collaborative transaction.",
|
||||
"error_starting_schedule_failed": "Starting the scheduler failed.",
|
||||
"error_stopping_schedule_failed": "Stopping the scheduler failed.",
|
||||
"error_starting_schedule_failed": "Starting a Scheduled Sweep failed. Reason: {{ reason }}",
|
||||
"error_stopping_schedule_failed": "Stopping Scheduled Sweep failed. Reason: {{ reason }}",
|
||||
"error_planning_schedule_failed": "Error while planning the Scheduled Sweep. Reason: {{ reason }}",
|
||||
"complete_wallet_title": "Complete Wallet",
|
||||
"complete_wallet_subtitle": "This will use all of your non-frozen funds.",
|
||||
"description_destination_addresses": "The scheduler will send all available funds to multiple destinations, splitting them up in random chunks.",
|
||||
"description_destination_addresses": "A Scheduled Sweep will send all available funds to multiple destinations, splitting them up in random chunks.",
|
||||
"feedback_invalid_destination_address": "Please enter a valid destination address.",
|
||||
"feedback_reused_destination_address": "This address is already used. To preserve your privacy please choose another one.",
|
||||
"label_destination_input": "Destination {{ destination }}",
|
||||
"placeholder_destination_input": "Enter destination address...",
|
||||
"description_fees": "The scheduler will pay all occurring fees automatically.",
|
||||
"button_start": "Start Scheduler",
|
||||
"button_stop": "Stop Scheduler",
|
||||
"progress_tldr_seconds": "Scheduled <1>{{ length }}</1> transactions over <3>{{ seconds }}</3> seconds.",
|
||||
"progress_tldr_hours": "Scheduled <1>{{ length }}</1> transactions over <3>{{ hours }}</3> hours.",
|
||||
"description_fees": "A Scheduled Sweep will pay all occurring fees automatically.",
|
||||
"scheduler_options": "Scheduled Sweep options",
|
||||
"button_start": "Start",
|
||||
"button_stop": "Stop",
|
||||
"button_plan": "Preview",
|
||||
"button_plan_renew": "Renew",
|
||||
"alert_scheduler_starting_title": "Starting Scheduled Sweep...",
|
||||
"alert_scheduler_stopping_title": "Stopping Scheduled Sweep...",
|
||||
"progress_tldr_seconds": "Scheduled <1>{{ length }}</1> actions over <3>{{ seconds }}</3> seconds.",
|
||||
"progress_tldr_hours": "Scheduled <1>{{ length }}</1> actions over <3>{{ hours }}</3> hours.",
|
||||
"progress_description": "This estimate is the minimum waiting time. Additional delays due to network communication or transaction confirmation are not considered.",
|
||||
"progress_current_state_wait_before_next": "Waiting {{ wait }} before creating and broadcasting transaction <1>{{ current }}</1> of <3>{{ total }}</3>.",
|
||||
"progress_current_state_waiting_confirmation": "Waiting for transaction <1>{{ current }}</1> of <3>{{ total }}</3> ({{ txid }}) to be confirmed.",
|
||||
"progress_current_state_transaction_confirmed": "Transaction <1>{{ current }}</1> of <3>{{ total }}</3> confirmed.",
|
||||
"progress_current_state_creating_next": "Creating and broadcasting transaction <1>{{ current }}</1> of <3>{{ total }}</3>...",
|
||||
"progress_wait_seconds": "{{ seconds }}s",
|
||||
"progress_wait_minutes": "{{ minutes }}m",
|
||||
"progress_wait_minutes_seconds": "{{ minutes }}m {{ seconds }}s",
|
||||
"progress_schedule_info_title": "Schedule details",
|
||||
"progress_entry_label": "Transaction {{ index }}",
|
||||
"progress_entry_state_pending": "Pending",
|
||||
"progress_entry_state_waiting_confirmation": "Waiting for confirmation ({{ txid }})",
|
||||
"progress_entry_state_confirmed": "Confirmed",
|
||||
"progress_entry_wait_before_next": "Wait before next: {{ wait }}",
|
||||
"progress_entry_wait_final": "Final transaction",
|
||||
"progress_done": "All transactions completed successfully. The scheduler will stop soon.",
|
||||
"progress_current_state_waiting_confirmation_title": "Waiting for action <1>{{ current }}</1> of <3>{{ total }}</3> to progress...",
|
||||
"progress_current_state_waiting_confirmation_description": "Transaction (<1>{{ txid }}</1>) needs to reach a minimum number of confirmations before continuing.",
|
||||
"progress_current_state_waiting_start_title": "Waiting for action <1>{{ current }}</1> of <3>{{ total }}</3> to start...",
|
||||
"progress_current_state_executing_title": "Executing action <1>{{ current }}</1> of <3>{{ total }}</3>...",
|
||||
"progress_entry_label": "Action {{ index }}",
|
||||
"progress_entry_wait_before_next_title": "Wait period before next action",
|
||||
"progress_done_awaiting_stop": "All actions completed successfully. The Scheduled Sweep will stop soon.",
|
||||
"precondition": {
|
||||
"hint_missing_utxos": "To run the scheduler you need UTXOs with {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
|
||||
"hint_missing_confirmations": "The scheduler requires your UTXOs to have {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
|
||||
"hint_missing_utxos": "A Scheduled Sweep needs UTXOs with {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
|
||||
"hint_missing_confirmations": "A Scheduled Sweep requires your UTXOs to have {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
|
||||
"hint_missing_retries": "Failed at sourcing commitments too many times. For security reasons, you need a fresh UTXO in the jar to try again. See <1>the docs</1> for more information.",
|
||||
"nested_hint_wait_for_block": "Wait for one more block.",
|
||||
"nested_hint_wait_for_block_other": "Wait for {{ count }} more blocks.",
|
||||
"nested_hint_fund_wallet": "Fund your wallet and wait for one more block.",
|
||||
"nested_hint_fund_wallet_other": "Fund your wallet and wait for {{ count }} more blocks."
|
||||
},
|
||||
"confirm_modal": {
|
||||
"confirm_start_modal": {
|
||||
"title": "Start Scheduled Sweep",
|
||||
"body": "Are you sure you want to start a scheduled sweep?"
|
||||
"text_body": "Are you sure you want to start a Scheduled Sweep?"
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
|
|
|
|||
|
|
@ -452,8 +452,8 @@
|
|||
"scheduler": {
|
||||
"title": "Spazzamento programmata (sperimentale)",
|
||||
"subtitle": "Eseguire transazioni multiple utilizzando importi e intervalli di tempo casuali per aumentare la propria privacy e quella altrui. Ogni transazione programmata è una transazione collaborativa.",
|
||||
"error_starting_schedule_failed": "L'avvio dello scheduler non è riuscito.",
|
||||
"error_stopping_schedule_failed": "L'arresto dello scheduler non è riuscito.",
|
||||
"error_starting_schedule_failed": "L'avvio dello scheduler non è riuscito. Motivo: {{ reason }}",
|
||||
"error_stopping_schedule_failed": "L'arresto dello scheduler non è riuscito. Motivo: {{ reason }}",
|
||||
"complete_wallet_title": "Completa portafoglio",
|
||||
"complete_wallet_subtitle": "In questo modo si utilizzeranno tutti i fondi non congelati.",
|
||||
"description_destination_addresses": "Lo scheduler invierà tutti i fondi disponibili a più destinazioni, suddividendoli in tranche casuali.",
|
||||
|
|
@ -467,7 +467,7 @@
|
|||
"progress_tldr_seconds": "Programmate <1>{{ length }}</1> transazioni su <3>{{ seconds }}</3> secondi.",
|
||||
"progress_tldr_hours": "Schedulate <1>{{ length }}</1> transazioni su <3>{{ hours }}</3> ore",
|
||||
"progress_description": "Questa stima rappresenta il tempo di attesa minimo. Non sono considerati i ritardi aggiuntivi dovuti alla comunicazione di rete o alla conferma della transazione.",
|
||||
"progress_done": "Tutte le transazioni sono state completate con successo. Lo scheduler si fermerà a breve.",
|
||||
"progress_done_awaiting_stop": "Tutte le transazioni sono state completate con successo. Lo scheduler si fermerà a breve.",
|
||||
"precondition": {
|
||||
"hint_missing_utxos": "Per eseguire lo scheduler sono necessari UTXO con {{ minConfirmations }} o più conferme. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
|
||||
"hint_missing_confirmations": "Lo scheduler richiede che gli UTXO abbiano {{ minConfirmations }} o più conferme. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
|
||||
|
|
|
|||
|
|
@ -452,8 +452,8 @@
|
|||
"scheduler": {
|
||||
"title": "Programar Limpeza (Experimental)",
|
||||
"subtitle": "Execute várias transações usando valores aleatórios e intervalos de tempo para aumentar a sua privacidade e a dos outros. Cada transação agendada é uma transação colaborativa.",
|
||||
"error_starting_schedule_failed": "Falha ao iniciar o agendador.",
|
||||
"error_stopping_schedule_failed": "Falha ao parar agendador.",
|
||||
"error_starting_schedule_failed": "Falha ao iniciar o agendador. Motivo: {{ reason }}",
|
||||
"error_stopping_schedule_failed": "Falha ao parar agendador. Motivo: {{ reason }}",
|
||||
"complete_wallet_title": "Carteira Completa",
|
||||
"complete_wallet_subtitle": "Isso usará todos os seus fundos não congelados.",
|
||||
"description_destination_addresses": "O agendador enviará todos os fundos disponíveis para vários destinos, dividindo-os em partes aleatórias.",
|
||||
|
|
@ -467,7 +467,7 @@
|
|||
"progress_tldr_seconds": "<1>{{ length }}</1> transações agendadas em <3>{{ seconds }}</3> segundos.",
|
||||
"progress_tldr_hours": "<1>{{ length }}</1> transações agendadas em <3>{{ hours }}</3> horas.",
|
||||
"progress_description": "Esta estimativa é o tempo mínimo de espera. Atrasos adicionais devido a comunicação de rede ou confirmação de transação não são considerados.",
|
||||
"progress_done": "Todas as transações foram concluídas com sucesso. O agendador irá parar em breve.",
|
||||
"progress_done_awaiting_stop": "Todas as transações foram concluídas com sucesso. O agendador irá parar em breve.",
|
||||
"precondition": {
|
||||
"hint_missing_utxos": "Para executar o agendador você precisa de UTXOs com {{ minConfirmations }} ou mais confirmações. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
|
||||
"hint_missing_confirmations": "O agendador exige que seus UTXOs tenham {{ minConfirmations }} ou mais confirmações. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
|
||||
|
|
|
|||
|
|
@ -544,8 +544,8 @@
|
|||
"scheduler": {
|
||||
"title": "Плановый Перенос средств (экспериментальная фича)",
|
||||
"subtitle": "Выполняйте несколько Совместных Транзакций для вывода всех доступных монет, используя случайные суммы и временные интервалы, чтобы повысить конфиденциальность себя и других. Каждая запланированная транзакция Переноса является Совместной Транзакцией.",
|
||||
"error_starting_schedule_failed": "Не удалось запустить планировщик Переноса.",
|
||||
"error_stopping_schedule_failed": "Не удалось остановить планировщик Переноса.",
|
||||
"error_starting_schedule_failed": "Не удалось запустить планировщик Переноса. Причина: {{ reason }}",
|
||||
"error_stopping_schedule_failed": "Не удалось остановить планировщик Переноса. Причина: {{ reason }}",
|
||||
"complete_wallet_title": "Весь кошелек",
|
||||
"complete_wallet_subtitle": "При этом будут использованы все ваши незамороженные средства.",
|
||||
"description_destination_addresses": "Планировщик Переноса отправит все доступные средства в несколько пунктов назначения, разделив их на случайные части.",
|
||||
|
|
@ -559,7 +559,7 @@
|
|||
"progress_tldr_seconds": "Запланировано <1>{{ length }}</1> Совместных Транзакций Переноса в течение <3>{{ seconds }}</3> секунд.",
|
||||
"progress_tldr_hours": "Запланировано <1>{{ length }}</1> Совместных Транзакций Переноса в течение <3>{{ hours }}</3> часов.",
|
||||
"progress_description": "Эта оценка является минимальным временем ожидания. Дополнительные задержки из-за сетевой коммуникации или подтверждения транзакции не учитываются.",
|
||||
"progress_done": "Все Совместные Транзакции Переноса завершены успешно. Планировщик скоро остановится.",
|
||||
"progress_done_awaiting_stop": "Все Совместные Транзакции Переноса завершены успешно. Планировщик скоро остановится.",
|
||||
"precondition": {
|
||||
"hint_missing_utxos": "Для запуска планировщика Переноса вам понадобятся UTXO с {{ minConfirmations }} или большим количеством подтверждений. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
|
||||
"hint_missing_confirmations": "Планировщик требует, чтобы ваши UTXO имели {{ minConfirmations }} или более подтверждений. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
|
||||
|
|
|
|||
|
|
@ -493,8 +493,8 @@
|
|||
"scheduler": {
|
||||
"title": "预定扫空(试验性)",
|
||||
"subtitle": "使用随机金额和时间间隔执行多次交易,以此增加你和他人的隐私程度。每个预定的交易都是协作式交易。",
|
||||
"error_starting_schedule_failed": "开始调度程序失败。",
|
||||
"error_stopping_schedule_failed": "停止调度程序失败。",
|
||||
"error_starting_schedule_failed": "开始调度程序失败。原因:{{ reason }}",
|
||||
"error_stopping_schedule_failed": "停止调度程序失败。原因:{{ reason }}",
|
||||
"complete_wallet_title": "整个钱包",
|
||||
"complete_wallet_subtitle": "这将使用你所有解冻的资金。",
|
||||
"description_destination_addresses": "调度程序将把所有可用资金随机分配并发送到多个目的地。",
|
||||
|
|
@ -508,7 +508,7 @@
|
|||
"progress_tldr_seconds": "在<3>{{ seconds }}</3>秒的范围内预定了<1>{{ length }}</1>个交易。",
|
||||
"progress_tldr_hours": "在<3>{{ hours }}</3>小时的范围内预定了<1>{{ length }}</1>个交易。",
|
||||
"progress_description": "此估计是最低等待时间。不计因网络通信或交易确认而造成的其他耽延。",
|
||||
"progress_done": "所有交易都已成功完成。调度程序即将停止。",
|
||||
"progress_done_awaiting_stop": "所有交易都已成功完成。调度程序即将停止。",
|
||||
"precondition": {
|
||||
"hint_missing_utxos": "要运行调度程序你需要具有至少{{ minConfirmations }}次确认的 UTXOs。$t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
|
||||
"hint_missing_confirmations": "调度程序要求你的 UTXOs 具有至少{{ minConfirmations }}次确认。$t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
|
||||
|
|
|
|||
|
|
@ -493,8 +493,8 @@
|
|||
"scheduler": {
|
||||
"title": "預定掃空(試驗性)",
|
||||
"subtitle": "使用隨機金額和時間間隔執行多次交易,以此增加你和他人的隱私程度。每個預定的交易都是協作式交易。",
|
||||
"error_starting_schedule_failed": "開始調度程序失敗。",
|
||||
"error_stopping_schedule_failed": "停止調度程序失敗。",
|
||||
"error_starting_schedule_failed": "開始調度程序失敗。原因: {{ reason }}",
|
||||
"error_stopping_schedule_failed": "停止調度程序失敗。原因: {{ reason }}",
|
||||
"complete_wallet_title": "整個錢包",
|
||||
"complete_wallet_subtitle": "這將使用你所有解凍的資金。",
|
||||
"description_destination_addresses": "調度程序將把所有可用資金隨機分配並發送到多個目的地。",
|
||||
|
|
@ -508,7 +508,7 @@
|
|||
"progress_tldr_seconds": "在<3>{{ seconds }}</3>的範圍內預定了<1>{{ length }}</1>個交易。",
|
||||
"progress_tldr_hours": "在<3>{{ hours }}</3>小時的範圍內預定了<1>{{ length }}</1>個交易。",
|
||||
"progress_description": "此估計是最低等待時間。不計因網路通信或交易確認而造成的其他耽延。",
|
||||
"progress_done": "所有交易都已成功完成。調度程序即將停止。",
|
||||
"progress_done_awaiting_stop": "所有交易都已成功完成。調度程序即將停止。",
|
||||
"precondition": {
|
||||
"hint_missing_utxos": "要運行調度程序你需要具有至少{{ minConfirmations }}次確認的 UTXOs。$t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
|
||||
"hint_missing_confirmations": "調度程序要求你的 UTXOs 具有至少{{ minConfirmations }}次確認。$t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { JM_WALLET_FILE_EXTENSION, type OfferType } from '@/constants/jm'
|
||||
import type { BlockHeight, Factor, Milliseconds, MnemonicPhrase } from '@/types/global'
|
||||
import type { BlockHeight, Factor, Milliseconds, Minutes, MnemonicPhrase, Seconds } from '@/types/global'
|
||||
|
||||
const HORIZONTAL_ELLIPSIS = '\u2026' // Horizontal Ellipsis `…`
|
||||
|
||||
|
|
@ -138,8 +138,11 @@ export const tryBtcToSat = (value: string): number | undefined => {
|
|||
}
|
||||
|
||||
export const SEGWIT_ACTIVATION_BLOCK: BlockHeight = 481_824 // https://github.com/bitcoin/bitcoin/blob/v25.0/src/kernel/chainparams.cpp#L86
|
||||
export const AVERAGE_BLOCKS_PER_DAY: number = Math.round((1 / 10) * 60 * 24)
|
||||
export const AVERAGE_BLOCKS_PER_HOUR: number = Math.round((1 / 10) * 60)
|
||||
export const AVERAGE_BLOCKS_PER_DAY: number = AVERAGE_BLOCKS_PER_HOUR * 24
|
||||
export const AVERAGE_BLOCKS_PER_YEAR: number = 365 * AVERAGE_BLOCKS_PER_DAY
|
||||
export const MEAN_DURATION_BETWEEN_BLOCKS_MINUTES: Minutes = 60 / AVERAGE_BLOCKS_PER_HOUR
|
||||
export const MEAN_DURATION_BETWEEN_BLOCKS_SECONDS: Seconds = MEAN_DURATION_BETWEEN_BLOCKS_MINUTES * 60
|
||||
|
||||
// if applicable, the genesis date can be used as minimum `since` timestamp
|
||||
export const BITCOIN_GENESIS_DATE = new Date('2009-01-03T18:15:05Z')
|
||||
|
|
@ -313,13 +316,13 @@ export const time = (() => {
|
|||
from?: Milliseconds
|
||||
to: Milliseconds
|
||||
locale: string
|
||||
}) => humanReadableTimeInterval(timeInterval({ from, to }), locale || 'en')
|
||||
}) => humanReadableRelativeTimeInterval(timeInterval({ from, to }), locale || 'en')
|
||||
|
||||
const timeInterval = ({ from = Date.now(), to }: { from?: Milliseconds; to: Milliseconds }): TimeInterval => {
|
||||
return to - from
|
||||
}
|
||||
|
||||
const humanReadableTimeInterval = (timeInterval: TimeInterval, locale: string) => {
|
||||
const humanReadableRelativeTimeInterval = (timeInterval: TimeInterval, locale: string) => {
|
||||
const rtf = new Intl.RelativeTimeFormat(locale || 'en', { numeric: 'always', style: 'long' })
|
||||
|
||||
const sortedUnits = (Object.keys(UNIT_MILLIS) as Unit[])
|
||||
|
|
@ -340,5 +343,6 @@ export const time = (() => {
|
|||
return {
|
||||
timeInterval,
|
||||
humanReadableDuration,
|
||||
humanReadableRelativeTimeInterval,
|
||||
}
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -1,79 +1,179 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Mail, Plus, PlusCircle, Search, UserPlus } from 'lucide-react'
|
||||
import { expect, userEvent, within } from 'storybook/test'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
const meta: Meta<typeof DropdownMenu> = {
|
||||
/**
|
||||
* Displays a menu to the user — such as a set of actions or functions —
|
||||
* triggered by a button.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Core/DropdownMenu',
|
||||
component: DropdownMenu,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
argTypes: {},
|
||||
render: (args) => (
|
||||
<DropdownMenu {...args}>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-44">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>Billing</DropdownMenuItem>
|
||||
<DropdownMenuItem>Team</DropdownMenuItem>
|
||||
<DropdownMenuItem>Subscription</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive">Delete wallet</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies Meta<typeof DropdownMenu>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof DropdownMenu>
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">Open menu</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuLabel>Wallet actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Rename wallet</DropdownMenuItem>
|
||||
/**
|
||||
* The default form of the dropdown menu.
|
||||
*/
|
||||
export const Default: Story = {}
|
||||
|
||||
/**
|
||||
* A dropdown menu with shortcuts.
|
||||
*/
|
||||
export const WithShortcuts: Story = {
|
||||
render: (args) => (
|
||||
<DropdownMenu {...args}>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-44">
|
||||
<DropdownMenuLabel>Controls</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
Export backup
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
Back
|
||||
<DropdownMenuShortcut>⌘[</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>
|
||||
Forward
|
||||
<DropdownMenuShortcut>⌘]</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive">Delete wallet</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithSelectionItems: Story = {
|
||||
render: () => (
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">Display options</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuCheckboxItem checked>Show balances</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Show frozen coins</DropdownMenuCheckboxItem>
|
||||
/**
|
||||
* A dropdown menu with submenus.
|
||||
*/
|
||||
export const WithSubmenus: Story = {
|
||||
render: (args) => (
|
||||
<DropdownMenu {...args}>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-44">
|
||||
<DropdownMenuItem>
|
||||
<Search className="mr-2 size-4" />
|
||||
<span>Search</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup value="btc">
|
||||
<DropdownMenuRadioItem value="btc">BTC</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="sats">Sats</DropdownMenuRadioItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Plus className="mr-2 size-4" />
|
||||
<span>New Team</span>
|
||||
<DropdownMenuShortcut>⌘+T</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<UserPlus className="mr-2 size-4" />
|
||||
<span>Invite users</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>
|
||||
<Mail className="mr-2 size-4" />
|
||||
<span>Email</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PlusCircle className="mr-2 size-4" />
|
||||
<span>More...</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* A dropdown menu with radio items.
|
||||
*/
|
||||
export const WithRadioItems: Story = {
|
||||
render: (args) => (
|
||||
<DropdownMenu {...args}>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-44">
|
||||
<DropdownMenuLabel inset>Status</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value="warning">
|
||||
<DropdownMenuRadioItem value="info">Info</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="warning">Warning</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="error">Error</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithDisabledItem: Story = {
|
||||
render: () => (
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">Wallet actions</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuLabel>Wallet actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Rename wallet</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>Export backup</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive">Delete wallet</DropdownMenuItem>
|
||||
/**
|
||||
* A dropdown menu with checkboxes.
|
||||
*/
|
||||
export const WithCheckboxes: Story = {
|
||||
render: (args) => (
|
||||
<DropdownMenu {...args}>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-44">
|
||||
<DropdownMenuCheckboxItem checked>
|
||||
Autosave
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Show Comments</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
}
|
||||
|
||||
export const ShouldOpenClose: Story = {
|
||||
name: 'when clicking an item, should close the dropdown menu',
|
||||
tags: ['!dev', '!autodocs'],
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const body = within(canvasElement.ownerDocument.body)
|
||||
|
||||
await step('Open the dropdown menu', async () => {
|
||||
await userEvent.click(await body.findByRole('button', { name: /open/i }))
|
||||
expect(await body.findByRole('menu')).toBeInTheDocument()
|
||||
})
|
||||
const items = await body.findAllByRole('menuitem')
|
||||
expect(items).toHaveLength(5)
|
||||
|
||||
await step('Click the first menu item', async () => {
|
||||
await userEvent.click(items[0], { delay: 100 })
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import React from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
|
|
@ -13,67 +14,334 @@ import {
|
|||
FieldTitle,
|
||||
} from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
const meta: Meta<typeof Field> = {
|
||||
title: 'Core/Field',
|
||||
component: Field,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
argTypes: {
|
||||
orientation: {
|
||||
control: 'select',
|
||||
options: ['vertical', 'horizontal', 'responsive'],
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
orientation: 'vertical',
|
||||
},
|
||||
decorators: (Story) => (
|
||||
<div className="w-full max-w-md min-w-sm">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
} satisfies Meta<typeof Field>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof Field>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<FieldGroup className="max-w-sm">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="wallet-name">Wallet name</FieldLabel>
|
||||
<Input id="wallet-name" placeholder="Enter wallet name" />
|
||||
<FieldDescription>This name is only used locally in Jam.</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
),
|
||||
}
|
||||
|
||||
export const Horizontal: Story = {
|
||||
render: () => (
|
||||
<Field orientation="horizontal" className="max-w-sm">
|
||||
<Checkbox id="remember-wallet" />
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="remember-wallet">Remember wallet</FieldLabel>
|
||||
<FieldDescription>Keep this wallet available in the wallet list.</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
render: () => (
|
||||
<FieldGroup className="max-w-sm">
|
||||
<Field data-invalid="true">
|
||||
<FieldLabel htmlFor="wallet-password">Wallet password</FieldLabel>
|
||||
<Input id="wallet-password" type="password" aria-invalid="true" />
|
||||
<FieldError>Password is required.</FieldError>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
),
|
||||
}
|
||||
|
||||
export const Fieldset: Story = {
|
||||
render: () => (
|
||||
<FieldSet className="max-w-sm">
|
||||
<FieldLegend>Fee settings</FieldLegend>
|
||||
/**
|
||||
* Field with Input component for text input.
|
||||
*/
|
||||
export const WithInput: Story = {
|
||||
render: (args) => (
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="min-fee">Minimum fee</FieldLabel>
|
||||
<Input id="min-fee" defaultValue="21" />
|
||||
</Field>
|
||||
<FieldSeparator>or</FieldSeparator>
|
||||
<Field>
|
||||
<FieldTitle>Use default fee</FieldTitle>
|
||||
<FieldDescription>Let Jam choose the default fee for this wallet.</FieldDescription>
|
||||
<Field {...args}>
|
||||
<FieldLabel htmlFor="username">Username</FieldLabel>
|
||||
<Input id="username" type="text" placeholder="Satoshi" />
|
||||
<FieldDescription>Choose a unique username for your account.</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
render: () => (
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<Field data-invalid="true">
|
||||
<FieldLabel htmlFor="wallet-password">Wallet password</FieldLabel>
|
||||
<Input id="wallet-password" type="password" aria-invalid="true" />
|
||||
<FieldError>Password is required.</FieldError>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithErrors: Story = {
|
||||
render: () => (
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<Field data-invalid="true">
|
||||
<FieldLabel htmlFor="wallet-password">Wallet password</FieldLabel>
|
||||
<Input id="wallet-password" type="password" aria-invalid="true" />
|
||||
<FieldError />
|
||||
<FieldError />
|
||||
<FieldError />
|
||||
<FieldError errors={[{ message: 'Password must be present' }]} />
|
||||
<FieldError
|
||||
errors={[
|
||||
{
|
||||
message: 'Password must longer than 32 chars',
|
||||
},
|
||||
{
|
||||
message: 'Password must shorter than 128 chars',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Field with textarea for longer text input.
|
||||
*/
|
||||
export const WithTextarea: Story = {
|
||||
render: (args) => (
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<Field {...args}>
|
||||
<FieldLabel htmlFor="feedback">Feedback</FieldLabel>
|
||||
<Textarea id="feedback" placeholder="Your feedback helps us improve..." rows={4} />
|
||||
<FieldDescription>Share your thoughts about our service.</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Field with Select component for dropdown selections.
|
||||
*/
|
||||
export const WithSelect: Story = {
|
||||
render: (args) => (
|
||||
<Field {...args}>
|
||||
<FieldLabel>Department</FieldLabel>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="engineering">Engineering</SelectItem>
|
||||
<SelectItem value="design">Design</SelectItem>
|
||||
<SelectItem value="marketing">Marketing</SelectItem>
|
||||
<SelectItem value="sales">Sales</SelectItem>
|
||||
<SelectItem value="support">Customer Support</SelectItem>
|
||||
<SelectItem value="hr">Human Resources</SelectItem>
|
||||
<SelectItem value="finance">Finance</SelectItem>
|
||||
<SelectItem value="operations">Operations</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>Select your department or area of work.</FieldDescription>
|
||||
</Field>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Field with Slider component and dynamic value display.
|
||||
*/
|
||||
export const WithSlider: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState([200, 800])
|
||||
return (
|
||||
<Field>
|
||||
<FieldTitle>Price Range</FieldTitle>
|
||||
<FieldDescription>
|
||||
Set your budget range ($
|
||||
<span className="font-medium tabular-nums">{value[0]}</span> -{' '}
|
||||
<span className="font-medium tabular-nums">{value[1]}</span>).
|
||||
</FieldDescription>
|
||||
<Slider
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
max={1_000}
|
||||
min={0}
|
||||
step={10}
|
||||
className="mt-2 w-full"
|
||||
aria-label="Price Range"
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* FieldSet with multiple related fields in a grid layout.
|
||||
*/
|
||||
export const WithFieldset: Story = {
|
||||
render: () => (
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<FieldSet>
|
||||
<FieldLegend>Address Information</FieldLegend>
|
||||
<FieldDescription>We need your address to deliver your order.</FieldDescription>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="street">Home Address</FieldLabel>
|
||||
<Input id="street" type="text" placeholder="123 Home St" />
|
||||
</Field>
|
||||
<FieldSeparator>or</FieldSeparator>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="company">Company Address</FieldLabel>
|
||||
<Input id="company" type="text" placeholder="456 Main St" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="city">City</FieldLabel>
|
||||
<Input id="city" type="text" placeholder="New York" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="zip">Postal Code</FieldLabel>
|
||||
<Input id="zip" type="text" placeholder="90502" />
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Field with checkbox inputs using FieldSet structure.
|
||||
*/
|
||||
export const WithCheckbox: Story = {
|
||||
render: () => (
|
||||
<FieldGroup>
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">Show these items on the desktop</FieldLegend>
|
||||
<FieldDescription>Select the items you want to show on the desktop.</FieldDescription>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="finder-pref-9k2-hard-disks-ljj" />
|
||||
<FieldLabel htmlFor="finder-pref-9k2-hard-disks-ljj" className="font-normal" defaultChecked>
|
||||
Hard disks
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="finder-pref-9k2-external-disks-1yg" />
|
||||
<FieldLabel htmlFor="finder-pref-9k2-external-disks-1yg" className="font-normal">
|
||||
External disks
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="finder-pref-9k2-cds-dvds-fzt" />
|
||||
<FieldLabel htmlFor="finder-pref-9k2-cds-dvds-fzt" className="font-normal">
|
||||
CDs, DVDs, and iPods
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="finder-pref-9k2-connected-servers-6l2" />
|
||||
<FieldLabel htmlFor="finder-pref-9k2-connected-servers-6l2" className="font-normal">
|
||||
Connected servers
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
<FieldSeparator />
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="finder-pref-9k2-sync-folders-nep" defaultChecked />
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="finder-pref-9k2-sync-folders-nep">Sync Desktop & Documents folders</FieldLabel>
|
||||
<FieldDescription>
|
||||
Your Desktop & Documents folders are being synced with iCloud Drive. You can access them from other devices.
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Field with RadioGroup for single selection.
|
||||
*/
|
||||
export const WithRadio: Story = {
|
||||
render: () => (
|
||||
<FieldSet>
|
||||
<FieldLabel>Subscription Plan</FieldLabel>
|
||||
<FieldDescription>Yearly and lifetime plans offer significant savings.</FieldDescription>
|
||||
<RadioGroup defaultValue="monthly">
|
||||
<Field orientation="horizontal">
|
||||
<RadioGroupItem value="monthly" id="plan-monthly" />
|
||||
<FieldLabel htmlFor="plan-monthly" className="font-normal">
|
||||
Monthly ($9.99/month)
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<RadioGroupItem value="yearly" id="plan-yearly" />
|
||||
<FieldLabel htmlFor="plan-yearly" className="font-normal">
|
||||
Yearly ($99.99/year)
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<RadioGroupItem value="lifetime" id="plan-lifetime" />
|
||||
<FieldLabel htmlFor="plan-lifetime" className="font-normal">
|
||||
Lifetime ($299.99)
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Field with Switch in horizontal orientation.
|
||||
*/
|
||||
export const WithSwitch: Story = {
|
||||
render: () => (
|
||||
<Field orientation="horizontal">
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="2fa">Multi-factor authentication</FieldLabel>
|
||||
<FieldDescription>
|
||||
Enable multi-factor authentication. If you do not have a two-factor device, you can use a one-time code sent
|
||||
to your email.
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
<Switch id="2fa" />
|
||||
</Field>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectable field groups with RadioItem for choice cards.
|
||||
*/
|
||||
export const ChoiceCard: Story = {
|
||||
render: () => (
|
||||
<FieldGroup>
|
||||
<FieldSet>
|
||||
<FieldLabel htmlFor="compute-environment-p8w">Compute Environment</FieldLabel>
|
||||
<FieldDescription>Select the compute environment for your cluster.</FieldDescription>
|
||||
<RadioGroup defaultValue="kubernetes">
|
||||
<FieldLabel htmlFor="kubernetes-r2h">
|
||||
<Field orientation="horizontal">
|
||||
<FieldContent>
|
||||
<FieldTitle>Kubernetes</FieldTitle>
|
||||
<FieldDescription>Run GPU workloads on a K8s configured cluster.</FieldDescription>
|
||||
</FieldContent>
|
||||
<RadioGroupItem value="kubernetes" id="kubernetes-r2h" />
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel htmlFor="vm-z4k">
|
||||
<Field orientation="horizontal">
|
||||
<FieldContent>
|
||||
<FieldTitle>Virtual Machine</FieldTitle>
|
||||
<FieldDescription>Access a VM configured cluster to run GPU workloads.</FieldDescription>
|
||||
</FieldContent>
|
||||
<RadioGroupItem value="vm" id="vm-z4k" />
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
),
|
||||
}
|
||||
|
|
|
|||
355
src/stories/Item.stories.tsx
Normal file
355
src/stories/Item.stories.tsx
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import * as React from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { BadgeCheckIcon, ChevronDownIcon, ChevronRightIcon, Plus, PlusIcon, ShieldAlertIcon } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from '@/components/ui/item'
|
||||
|
||||
/**
|
||||
* A versatile component that you can use to display any content.
|
||||
*/
|
||||
const meta: Meta<typeof Item> = {
|
||||
title: 'Core/Item',
|
||||
component: Item,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'outline', 'muted'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['default', 'sm'],
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
} satisfies Meta<typeof Item>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
/**
|
||||
* Basic item with title, description, and actions.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
render: (args) => (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Item {...args} variant="outline">
|
||||
<ItemContent>
|
||||
<ItemTitle>Basic Item</ItemTitle>
|
||||
<ItemDescription>A simple item with title and description.</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button variant="outline" size="sm">
|
||||
Action
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<a href="#">
|
||||
<ItemMedia>
|
||||
<BadgeCheckIcon className="size-5" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Your profile has been verified.</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the `outline` variant to add a visible border to the item.
|
||||
*/
|
||||
export const Outline: Story = {
|
||||
render: (args) => (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Item {...args} variant="outline">
|
||||
<ItemMedia>
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage className="bg-white" src="/apple-touch-icon.png" />
|
||||
<AvatarFallback>JM</AvatarFallback>
|
||||
</Avatar>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Software Update Available</ItemTitle>
|
||||
<ItemDescription>Version 2.0 is now available for download.</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button size="sm" variant="outline">
|
||||
Update
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the `muted` variant to add a subtle background to the item.
|
||||
*/
|
||||
export const Muted: Story = {
|
||||
render: (args) => (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Item {...args} variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<BadgeCheckIcon />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Account Verified</ItemTitle>
|
||||
<ItemDescription>Your account has been successfully verified.</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button size="sm" variant="ghost">
|
||||
Dismiss
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the `sm` size for a more compact item layout.
|
||||
*/
|
||||
export const Small: Story = {
|
||||
render: (args) => (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<ItemGroup>
|
||||
<Item {...args} variant="outline" size="sm">
|
||||
<ItemMedia>
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage className="bg-white" src="/apple-touch-icon.png" />
|
||||
<AvatarFallback>JM</AvatarFallback>
|
||||
</Avatar>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>New message from Jam</ItemTitle>
|
||||
<ItemDescription>Hey, how are you doing?</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<ItemSeparator />
|
||||
<Item variant="outline" size="sm">
|
||||
<ItemMedia>
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage className="bg-white" src="/apple-touch-icon.png" />
|
||||
<AvatarFallback>ML</AvatarFallback>
|
||||
</Avatar>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>New message from johndoe</ItemTitle>
|
||||
<ItemDescription>Check out this new feature!</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Item with icon media element.
|
||||
*/
|
||||
export const WithIcon: Story = {
|
||||
render: (args) => (
|
||||
<div className="flex w-full max-w-lg flex-col gap-6">
|
||||
<Item {...args} variant="outline">
|
||||
<ItemMedia variant="icon">
|
||||
<ShieldAlertIcon />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Security Alert</ItemTitle>
|
||||
<ItemDescription>New login detected from unknown device.</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button size="sm" variant="outline">
|
||||
Review
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Items with avatar media elements.
|
||||
*/
|
||||
export const WithAvatar: Story = {
|
||||
render: (args) => (
|
||||
<div className="flex w-full max-w-lg flex-col gap-6">
|
||||
<Item {...args} variant="outline">
|
||||
<ItemMedia>
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage className="bg-white" src="/apple-touch-icon.png" />
|
||||
<AvatarFallback>HR</AvatarFallback>
|
||||
</Avatar>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>HR</ItemTitle>
|
||||
<ItemDescription>Last seen 5 months ago</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button size="icon-sm" variant="outline" className="rounded-full" aria-label="Invite">
|
||||
<Plus />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item variant="outline">
|
||||
<ItemMedia>
|
||||
<div className="*:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:grayscale">
|
||||
<Avatar className="hidden sm:flex">
|
||||
<AvatarImage className="bg-white" src="/apple-touch-icon.png" alt="@Jam" />
|
||||
<AvatarFallback>JM</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className="hidden sm:flex">
|
||||
<AvatarImage className="bg-white" src="/apple-touch-icon.png" alt="@johndoe" />
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarImage className="bg-white" src="/apple-touch-icon.png" alt="@hr" />
|
||||
<AvatarFallback>HR</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>No Team Members</ItemTitle>
|
||||
<ItemDescription>Invite your team to collaborate on this project.</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button size="sm" variant="outline">
|
||||
Invite
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped items with separators.
|
||||
*/
|
||||
export const WithGroup: Story = {
|
||||
render: (args) => {
|
||||
const people = [
|
||||
{
|
||||
username: 'Jam',
|
||||
avatar: '/apple-touch-icon.png',
|
||||
email: 'Jam@example.org',
|
||||
},
|
||||
{
|
||||
username: 'johndoe',
|
||||
avatar: '/apple-touch-icon.png',
|
||||
email: 'johndoe@example.org',
|
||||
},
|
||||
{
|
||||
username: 'hr',
|
||||
avatar: '/apple-touch-icon.png',
|
||||
email: 'hr@example.org',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<ItemGroup>
|
||||
{people.map((person, index) => (
|
||||
<React.Fragment key={person.username}>
|
||||
<Item {...args}>
|
||||
<ItemMedia>
|
||||
<Avatar>
|
||||
<AvatarImage className="bg-white grayscale" src={person.avatar} />
|
||||
<AvatarFallback>{person.username.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</ItemMedia>
|
||||
<ItemContent className="gap-1">
|
||||
<ItemTitle>{person.username}</ItemTitle>
|
||||
<ItemDescription>{person.email}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
{index !== people.length - 1 && <ItemSeparator />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Items in a dropdown menu.
|
||||
*/
|
||||
export const WithDropdown: Story = {
|
||||
render: (args) => {
|
||||
const people = [
|
||||
{
|
||||
username: 'Jam',
|
||||
avatar: '/apple-touch-icon.png',
|
||||
email: 'Jam@example.org',
|
||||
},
|
||||
{
|
||||
username: 'johndoe',
|
||||
avatar: '/apple-touch-icon.png',
|
||||
email: 'johndoe@example.org',
|
||||
},
|
||||
{
|
||||
username: 'hr',
|
||||
avatar: '/apple-touch-icon.png',
|
||||
email: 'hr@example.org',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex min-h-64 w-full max-w-md flex-col items-center gap-6">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-fit">
|
||||
Select <ChevronDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-72 [--radius:0.65rem]" align="end">
|
||||
{people.map((person) => (
|
||||
<DropdownMenuItem key={person.username} className="p-0">
|
||||
<Item {...args} size="sm" className="w-full p-2">
|
||||
<ItemMedia>
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage className="bg-white grayscale" src={person.avatar} />
|
||||
<AvatarFallback>{person.username.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</ItemMedia>
|
||||
<ItemContent className="gap-0.5">
|
||||
<ItemTitle>{person.username}</ItemTitle>
|
||||
<ItemDescription>{person.email}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
179
src/stories/Sidebar.stories.tsx
Normal file
179
src/stories/Sidebar.stories.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Calendar, ChevronUp, Home, Inbox, Search, Settings, User2 } from 'lucide-react'
|
||||
import { userEvent } from 'storybook/test'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
/**
|
||||
* A composable, themeable and customizable sidebar component.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Core/Sidebar',
|
||||
component: Sidebar,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
side: {
|
||||
options: ['left', 'right'],
|
||||
control: { type: 'radio' },
|
||||
},
|
||||
variant: {
|
||||
options: ['sidebar', 'floating', 'inset'],
|
||||
control: { type: 'radio' },
|
||||
},
|
||||
collapsible: {
|
||||
options: ['offcanvas', 'icon', 'none'],
|
||||
control: { type: 'radio' },
|
||||
},
|
||||
},
|
||||
args: {
|
||||
side: 'left',
|
||||
variant: 'sidebar',
|
||||
collapsible: 'icon',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<SidebarProvider className="min-h-[380px]">
|
||||
<Story />
|
||||
<section className="m-4">
|
||||
<SidebarTrigger />
|
||||
<div className="size-full" />
|
||||
</section>
|
||||
</SidebarProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof Sidebar>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof Sidebar>
|
||||
|
||||
// Menu items.
|
||||
const items = [
|
||||
{
|
||||
title: 'Home',
|
||||
url: '#',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: 'Inbox',
|
||||
url: '#',
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: 'Calendar',
|
||||
url: '#',
|
||||
icon: Calendar,
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
url: '#',
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
url: '#',
|
||||
icon: Settings,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* A simple sidebar with a group of menu items.
|
||||
*/
|
||||
export const Simple: Story = {
|
||||
render: (args) => (
|
||||
<Sidebar {...args}>
|
||||
<SidebarHeader />
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Application</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
</Sidebar>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple sidebar with a footer menu item.
|
||||
*/
|
||||
export const Footer: Story = {
|
||||
render: (args) => (
|
||||
<Sidebar {...args}>
|
||||
<SidebarHeader />
|
||||
<SidebarContent />
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<User2 /> Username
|
||||
<ChevronUp className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" className="w-(--radix-popper-anchor-width)">
|
||||
<DropdownMenuItem>
|
||||
<span>Account</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<span>Billing</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<span>Sign out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
),
|
||||
}
|
||||
|
||||
export const ShouldCloseOpen: Story = {
|
||||
...Simple,
|
||||
name: 'when clicking the trigger, should close and open the sidebar',
|
||||
tags: ['!dev', '!autodocs'],
|
||||
play: async ({ canvas, step }) => {
|
||||
const sidebarButton = await canvas.findByRole('button', {
|
||||
name: /toggle/i,
|
||||
})
|
||||
await step('close the sidebar', async () => {
|
||||
await userEvent.click(sidebarButton)
|
||||
})
|
||||
|
||||
await step('reopen the sidebar', async () => {
|
||||
await userEvent.click(sidebarButton)
|
||||
})
|
||||
},
|
||||
}
|
||||
64
src/stories/Slider.stories.tsx
Normal file
64
src/stories/Slider.stories.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
|
||||
/**
|
||||
* An input where the user selects a value from within a given range.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Core/Slider',
|
||||
component: Slider,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {},
|
||||
args: {
|
||||
defaultValue: [33],
|
||||
max: 100,
|
||||
step: 1,
|
||||
minStepsBetweenThumbs: 0,
|
||||
},
|
||||
} satisfies Meta<typeof Slider>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
/**
|
||||
* The default form of the slider.
|
||||
*/
|
||||
export const Default: Story = {}
|
||||
|
||||
/**
|
||||
* Use the `orientation` prop to render a vertical slider.
|
||||
*/
|
||||
export const Vertical: Story = {
|
||||
args: {
|
||||
orientation: 'vertical',
|
||||
className: 'h-40',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the `inverted` prop to have the slider fill from right to left.
|
||||
*/
|
||||
export const Inverted: Story = {
|
||||
args: {
|
||||
inverted: true,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the `disabled` prop to disable the slider.
|
||||
*/
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the `disabled` prop to disable the slider.
|
||||
*/
|
||||
export const Multiple: Story = {
|
||||
args: {
|
||||
defaultValue: [33, 66],
|
||||
},
|
||||
}
|
||||
147
src/stories/jam/ScheduleEntryItem.stories.tsx
Normal file
147
src/stories/jam/ScheduleEntryItem.stories.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import type { TumblerPhaseResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { ScheduleEntryItem } from '@/components/sweep/ScheduleEntryItem'
|
||||
import { toScheduleEntry } from '@/components/sweep/scheduleUtils'
|
||||
|
||||
const meta: Meta<typeof ScheduleEntryItem> = {
|
||||
title: 'Jam/ScheduleEntryItem',
|
||||
component: ScheduleEntryItem,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof ScheduleEntryItem>
|
||||
|
||||
const takerPhase: TumblerPhaseResponse = {
|
||||
kind: 'taker_coinjoin',
|
||||
index: 0,
|
||||
status: 'running',
|
||||
wait_seconds: 20.8818214051562,
|
||||
started_at: '2026-07-19T10:35:52.775747+00:00',
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: 0,
|
||||
amount: 0,
|
||||
amount_fraction: null,
|
||||
counterparty_count: 21,
|
||||
destination: 'INTERNAL',
|
||||
txid: null,
|
||||
duration_seconds: null,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: null,
|
||||
cj_served: null,
|
||||
attempt_count: 0,
|
||||
}
|
||||
|
||||
const makerPhase: TumblerPhaseResponse = {
|
||||
kind: 'maker_session',
|
||||
index: 4,
|
||||
status: 'pending',
|
||||
wait_seconds: 1.5946546346591093,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error: null,
|
||||
mixdepth: null,
|
||||
amount: null,
|
||||
amount_fraction: null,
|
||||
counterparty_count: null,
|
||||
destination: null,
|
||||
txid: null,
|
||||
duration_seconds: 43200,
|
||||
target_cj_count: null,
|
||||
idle_timeout_seconds: 60,
|
||||
cj_served: 0,
|
||||
attempt_count: 0,
|
||||
}
|
||||
|
||||
export const Active: Story = {
|
||||
args: {
|
||||
value: toScheduleEntry(takerPhase, []),
|
||||
active: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const Running: Story = {
|
||||
args: {
|
||||
value: toScheduleEntry(
|
||||
{
|
||||
...takerPhase,
|
||||
status: 'running',
|
||||
},
|
||||
[],
|
||||
),
|
||||
active: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
value: toScheduleEntry(
|
||||
{
|
||||
...takerPhase,
|
||||
status: 'pending',
|
||||
},
|
||||
[],
|
||||
),
|
||||
active: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Completed: Story = {
|
||||
args: {
|
||||
value: toScheduleEntry(
|
||||
{
|
||||
...takerPhase,
|
||||
status: 'completed',
|
||||
},
|
||||
[],
|
||||
),
|
||||
active: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Skipped: Story = {
|
||||
args: {
|
||||
value: toScheduleEntry(
|
||||
{
|
||||
...takerPhase,
|
||||
status: 'skipped',
|
||||
},
|
||||
[],
|
||||
),
|
||||
active: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Cancelled: Story = {
|
||||
args: {
|
||||
value: toScheduleEntry(
|
||||
{
|
||||
...takerPhase,
|
||||
status: 'cancelled',
|
||||
},
|
||||
[],
|
||||
),
|
||||
active: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
value: toScheduleEntry(
|
||||
{
|
||||
...takerPhase,
|
||||
status: 'failed',
|
||||
error: 'Error description',
|
||||
},
|
||||
[],
|
||||
),
|
||||
active: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Maker: Story = {
|
||||
args: {
|
||||
value: toScheduleEntry(makerPhase, []),
|
||||
},
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StatusBadge } from '@/components/ui/jam/StatusBadge'
|
||||
import { UTXO_STATUS_TAG_VARIANTS } from '@/lib/tags'
|
||||
|
||||
|
|
@ -24,3 +25,26 @@ export const All: Story = {
|
|||
</>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithTooltip: Story = {
|
||||
render: () => (
|
||||
<>
|
||||
<StatusBadge tooltip="Tooltip content">Hover me</StatusBadge>
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
export const ButtonAsChild: Story = {
|
||||
render: () => (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-5">
|
||||
<StatusBadge asChild variant="cj-out">
|
||||
<Button variant="ghost">cj-out</Button>
|
||||
</StatusBadge>
|
||||
<StatusBadge asChild variant="cj-change">
|
||||
<Button variant="ghost">cj-change</Button>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,18 @@ const mergeStoryArguments = (metaArguments?: Record<string, unknown>, storyArgum
|
|||
...storyArguments,
|
||||
})
|
||||
|
||||
type DecoratorFn = (Story: ComponentType<Record<string, unknown>>) => ReactElement
|
||||
|
||||
const toArray = <T>(value: T | T[] | undefined): T[] => {
|
||||
if (value === undefined) {
|
||||
return []
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
return [value]
|
||||
}
|
||||
|
||||
describe('storybook stories', () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line compat/compat -- jsdom lacks ResizeObserver, but Radix-powered stories expect it.
|
||||
|
|
@ -143,6 +155,21 @@ describe('storybook stories', () => {
|
|||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
// used is-mobile (e.g. sidebar)
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: unknown) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // Deprecated
|
||||
removeListener: vi.fn(), // Deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
it('loads every story module', () => {
|
||||
|
|
@ -160,7 +187,13 @@ describe('storybook stories', () => {
|
|||
|
||||
for (const module of Object.values(modules)) {
|
||||
const meta = (
|
||||
module as { default?: { args?: Record<string, unknown>; component?: ComponentType<Record<string, unknown>> } }
|
||||
module as {
|
||||
default?: {
|
||||
args?: Record<string, unknown>
|
||||
component?: ComponentType<Record<string, unknown>>
|
||||
decorators?: Array<DecoratorFn>
|
||||
}
|
||||
}
|
||||
).default
|
||||
|
||||
for (const [name, story] of Object.entries(module as Record<string, unknown>)) {
|
||||
|
|
@ -170,7 +203,7 @@ describe('storybook stories', () => {
|
|||
|
||||
const storyConfig = story as {
|
||||
args?: Record<string, unknown>
|
||||
decorators?: Array<(Story: ComponentType<Record<string, unknown>>) => ReactElement>
|
||||
decorators?: Array<DecoratorFn>
|
||||
render?: (args?: Record<string, unknown>) => ReactElement
|
||||
}
|
||||
let StoryComponent: ComponentType<Record<string, unknown>>
|
||||
|
|
@ -185,8 +218,7 @@ describe('storybook stories', () => {
|
|||
continue
|
||||
}
|
||||
|
||||
const decorators: Array<(Story: ComponentType<Record<string, unknown>>) => ReactElement> =
|
||||
storyConfig.decorators ?? []
|
||||
const decorators: Array<DecoratorFn> = [...toArray(meta?.decorators), ...toArray(storyConfig.decorators)]
|
||||
|
||||
for (const decorator of decorators) {
|
||||
const DecoratedStory: ComponentType<Record<string, unknown>> = StoryComponent
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue