Skip to content

Commit e122e56

Browse files
authored
feat: make form state persistent between route changes (#146)
* feat(packages): make form state persistent between route changes * refactor(packages): rename variable * fix(packages): lint * chore(packages): simplify * fix(packages): lint
1 parent 55caba8 commit e122e56

9 files changed

Lines changed: 192 additions & 38 deletions

File tree

services/simple-staking/src/ui/baby/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ function BabyLayoutContent() {
112112
as="main"
113113
className="mx-auto flex max-w-[760px] flex-1 flex-col gap-[3rem] pb-24"
114114
>
115-
<Tabs items={fallbackTabItems} defaultActiveTab="stake" />
115+
<Tabs items={fallbackTabItems} defaultActiveTab="stake" keepMounted />
116116
</Container>
117117
);
118118

services/simple-staking/src/ui/baby/widgets/StakingForm/index.tsx

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
11
import { Form } from "@babylonlabs-io/core-ui";
2+
import { useMemo } from "react";
3+
import { DeepPartial } from "react-hook-form";
24

35
import { AmountField } from "@/ui/baby/components/AmountField";
46
import { FeeField } from "@/ui/baby/components/FeeField";
5-
import { useStakingState } from "@/ui/baby/state/StakingState";
7+
import { useStakingState, type FormData } from "@/ui/baby/state/StakingState";
68
import { StakingModal } from "@/ui/baby/widgets/StakingModal";
79
import { SubmitButton } from "@/ui/baby/widgets/SubmitButton";
810
import { ValidatorField } from "@/ui/baby/widgets/ValidatorField";
911
import { FormAlert } from "@/ui/common/components/Multistaking/MultistakingForm/FormAlert";
10-
11-
interface FormFields {
12-
amount: number;
13-
validatorAddresses: string[];
14-
feeAmount: number;
15-
}
12+
import { useFormPersistenceState } from "@/ui/common/state/FormPersistenceState";
1613

1714
interface StakingFormProps {
1815
isGeoBlocked?: boolean;
1916
}
2017

18+
/**
19+
* StakingForm supports multi-validator selection and draft persistence, so it
20+
* uses 'validatorAddresses' (string[]) instead of the single 'validatorAddress'
21+
* expected by 'FormData' in 'StakingState'.
22+
*
23+
* This interface removes 'validatorAddress' from 'FormData' and adds
24+
* 'validatorAddresses' to align with the validation schema and 'FormPersistenceState'.
25+
*/
26+
export interface StakingFormFields extends Omit<FormData, "validatorAddress"> {
27+
validatorAddresses: string[];
28+
}
29+
2130
export default function StakingForm({
2231
isGeoBlocked = false,
2332
}: StakingFormProps) {
@@ -31,19 +40,44 @@ export default function StakingForm({
3140
disabled,
3241
} = useStakingState();
3342

43+
const { babyStakeDraft, setBabyStakeDraft } = useFormPersistenceState();
44+
45+
const defaultValues = useMemo<Partial<StakingFormFields>>(() => {
46+
return {
47+
amount: babyStakeDraft?.amount,
48+
validatorAddresses: babyStakeDraft?.validatorAddresses,
49+
feeAmount: babyStakeDraft?.feeAmount,
50+
};
51+
}, [babyStakeDraft]);
52+
3453
const handlePreview = ({
3554
amount,
3655
validatorAddresses,
3756
feeAmount,
38-
}: FormFields) => {
39-
showPreview({ amount, feeAmount, validatorAddress: validatorAddresses[0] });
57+
}: Required<StakingFormFields>) => {
58+
showPreview({
59+
amount,
60+
feeAmount,
61+
validatorAddress: validatorAddresses[0],
62+
});
63+
};
64+
65+
const handleChange = (data: DeepPartial<StakingFormFields>) => {
66+
setBabyStakeDraft({
67+
...data,
68+
validatorAddresses: data.validatorAddresses?.filter(
69+
(i) => i !== undefined,
70+
),
71+
});
4072
};
4173

4274
return (
4375
<Form
4476
schema={formSchema}
4577
className="flex h-[500px] flex-col gap-2"
4678
onSubmit={handlePreview}
79+
defaultValues={defaultValues}
80+
onChange={handleChange}
4781
>
4882
<AmountField balance={availableBalance} price={babyPrice} />
4983
<ValidatorField />

services/simple-staking/src/ui/common/components/Multistaking/MultistakingForm/MultistakingForm.tsx

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Form } from "@babylonlabs-io/core-ui";
2-
import { useCallback } from "react";
2+
import { useCallback, useMemo } from "react";
3+
import { DeepPartial } from "react-hook-form";
34

5+
import { useFormPersistenceState } from "@/ui/common/state/FormPersistenceState";
46
import {
57
useMultistakingState,
68
type MultistakingFormFields,
@@ -12,31 +14,64 @@ import { MultistakingFormContent } from "./MultistakingFormContent";
1214
export function MultistakingForm() {
1315
const { stakingInfo, setFormData, goToStep } = useStakingState();
1416
const { validationSchema } = useMultistakingState();
17+
const { btcStakeDraft, setBtcStakeDraft } = useFormPersistenceState();
18+
19+
const defaultValues = useMemo<Partial<MultistakingFormFields>>(
20+
() => ({
21+
finalityProviders: btcStakeDraft?.finalityProviders,
22+
amount: btcStakeDraft?.amount,
23+
term: btcStakeDraft?.term ?? stakingInfo?.defaultStakingTimeBlocks,
24+
feeRate: btcStakeDraft?.feeRate ?? stakingInfo?.defaultFeeRate ?? 0,
25+
feeAmount: btcStakeDraft?.feeAmount,
26+
}),
27+
[
28+
btcStakeDraft,
29+
stakingInfo?.defaultStakingTimeBlocks,
30+
stakingInfo?.defaultFeeRate,
31+
],
32+
);
1533

1634
const handlePreview = useCallback(
17-
(formValues: MultistakingFormFields) => {
35+
(formValues: Required<MultistakingFormFields>) => {
1836
setFormData({
1937
finalityProviders: Object.values(formValues.finalityProviders),
20-
term: Number(formValues.term),
21-
amount: Number(formValues.amount),
22-
feeRate: Number(formValues.feeRate),
23-
feeAmount: Number(formValues.feeAmount),
38+
term: formValues.term,
39+
amount: formValues.amount,
40+
feeRate: formValues.feeRate,
41+
feeAmount: formValues.feeAmount,
2442
});
2543

2644
goToStep(StakingStep.PREVIEW);
2745
},
2846
[setFormData, goToStep],
2947
);
3048

49+
const handleChange = (data: DeepPartial<MultistakingFormFields>) => {
50+
const sanitizedFinalityProviders: Record<string, string> = {};
51+
52+
Object.entries(data.finalityProviders ?? {}).forEach(([key, value]) => {
53+
if (typeof value === "string") {
54+
sanitizedFinalityProviders[key] = value;
55+
}
56+
});
57+
58+
setBtcStakeDraft({
59+
...data,
60+
finalityProviders: sanitizedFinalityProviders,
61+
});
62+
};
63+
3164
if (!stakingInfo) {
3265
return null;
3366
}
3467

3568
return (
3669
<Form
37-
schema={validationSchema as any}
70+
schema={validationSchema}
3871
mode="onChange"
3972
reValidateMode="onChange"
73+
defaultValues={defaultValues}
74+
onChange={handleChange}
4075
onSubmit={handlePreview}
4176
>
4277
<MultistakingFormContent />

services/simple-staking/src/ui/common/components/Multistaking/MultistakingForm/StakingFeesSection.tsx

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { getNetworkConfigBTC } from "@/ui/common/config/network/btc";
1111
import { BBN_FEE_AMOUNT } from "@/ui/common/constants";
1212
import { usePrice } from "@/ui/common/hooks/client/api/usePrices";
1313
import { useStakingService } from "@/ui/common/hooks/services/useStakingService";
14-
import { useStakingState } from "@/ui/common/state/StakingState";
1514
import { satoshiToBtc } from "@/ui/common/utils/btc";
1615
import { calculateTokenValueInCurrency } from "@/ui/common/utils/formatCurrency";
1716
import { maxDecimals } from "@/ui/common/utils/maxDecimals";
@@ -30,17 +29,6 @@ export function StakingFeesSection() {
3029
);
3130

3231
const { calculateFeeAmount } = useStakingService();
33-
const { stakingInfo } = useStakingState();
34-
35-
useEffect(() => {
36-
if (stakingInfo?.defaultFeeRate !== undefined) {
37-
setValue("feeRate", stakingInfo.defaultFeeRate.toString(), {
38-
shouldValidate: true,
39-
shouldDirty: true,
40-
shouldTouch: true,
41-
});
42-
}
43-
}, [stakingInfo?.defaultFeeRate, setValue]);
4432

4533
useEffect(() => {
4634
let cancelled = false;
@@ -162,6 +150,7 @@ export function StakingFeesSection() {
162150
open={feeModalVisible}
163151
onClose={() => setFeeModalVisible(false)}
164152
onSubmit={handleFeeRateSubmit}
153+
currentFeeRate={Number(feeRate || 0)}
165154
/>
166155
</>
167156
);

services/simple-staking/src/ui/common/components/Staking/FeeModal/index.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,15 @@ interface FeeModalProps {
2222
open?: boolean;
2323
onSubmit?: (value: number) => void;
2424
onClose?: () => void;
25+
currentFeeRate?: number;
2526
}
2627

27-
export function FeeModal({ open, onSubmit, onClose }: FeeModalProps) {
28+
export function FeeModal({
29+
open,
30+
onSubmit,
31+
onClose,
32+
currentFeeRate,
33+
}: FeeModalProps) {
2834
const [selectedValue, setSelectedValue] = useState("");
2935
const [customFee, setCustomFee] = useState("");
3036
const customFeeRef = useRef<HTMLInputElement>(null);
@@ -45,6 +51,32 @@ export function FeeModal({ open, onSubmit, onClose }: FeeModalProps) {
4551
}
4652
}, [selectedValue]);
4753

54+
// Initialize selection based on current fee rate when opening
55+
useEffect(() => {
56+
if (!open || isLoading) return;
57+
58+
const fee = Number(currentFeeRate);
59+
if (!fee || !Number.isFinite(fee)) {
60+
setSelectedValue("");
61+
setCustomFee("");
62+
return;
63+
}
64+
65+
if (fee === fastestFee) {
66+
setSelectedValue("fast");
67+
setCustomFee("");
68+
} else if (fee === mediumFee) {
69+
setSelectedValue("medium");
70+
setCustomFee("");
71+
} else if (fee === lowestFee) {
72+
setSelectedValue("slow");
73+
setCustomFee("");
74+
} else {
75+
setSelectedValue("custom");
76+
setCustomFee(fee.toString());
77+
}
78+
}, [open, isLoading, currentFeeRate, fastestFee, mediumFee, lowestFee]);
79+
4880
const feeOptions = [
4981
{
5082
label: (

services/simple-staking/src/ui/common/components/Tabs/Tabs.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface TabsProps {
1313
activeTab?: string;
1414
onTabChange?: (tabId: string) => void;
1515
className?: string;
16+
keepMounted?: boolean;
1617
}
1718

1819
export const Tabs = ({
@@ -21,6 +22,7 @@ export const Tabs = ({
2122
activeTab: controlledActiveTab,
2223
onTabChange,
2324
className,
25+
keepMounted,
2426
}: TabsProps) => {
2527
const [internalActiveTab, setInternalActiveTab] = useState(
2628
defaultActiveTab || items[0]?.id || "",
@@ -69,14 +71,30 @@ export const Tabs = ({
6971
))}
7072
</div>
7173

72-
<div
73-
className="mt-6 min-h-[450px]"
74-
role="tabpanel"
75-
id={`panel-${activeTab}`}
76-
aria-labelledby={`tab-${activeTab}`}
77-
>
78-
{activeContent}
79-
</div>
74+
{keepMounted ? (
75+
<div className="mt-6 min-h-[450px]">
76+
{items.map((item) => (
77+
<div
78+
key={item.id}
79+
role="tabpanel"
80+
id={`panel-${item.id}`}
81+
aria-labelledby={`tab-${item.id}`}
82+
className={twMerge(activeTab === item.id ? "" : "hidden")}
83+
>
84+
{item.content}
85+
</div>
86+
))}
87+
</div>
88+
) : (
89+
<div
90+
className="mt-6 min-h-[450px]"
91+
role="tabpanel"
92+
id={`panel-${activeTab}`}
93+
aria-labelledby={`tab-${activeTab}`}
94+
>
95+
{activeContent}
96+
</div>
97+
)}
8098
</div>
8199
);
82100
};

services/simple-staking/src/ui/common/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const Home = () => {
6868
defaultActiveTab="stake"
6969
activeTab={activeTab}
7070
onTabChange={setActiveTab}
71+
keepMounted
7172
/>
7273
</Container>
7374
);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useMemo, useState, type PropsWithChildren } from "react";
2+
3+
import { type MultistakingFormFields } from "@/ui/common/state/MultistakingState";
4+
import { createStateUtils } from "@/ui/common/utils/createStateUtils";
5+
import { type StakingFormFields } from "@/ui/baby/widgets/StakingForm";
6+
7+
interface FormPersistenceContext {
8+
btcStakeDraft?: Partial<MultistakingFormFields> | undefined;
9+
babyStakeDraft?: Partial<StakingFormFields> | undefined;
10+
setBtcStakeDraft: (draft?: Partial<MultistakingFormFields>) => void;
11+
setBabyStakeDraft: (draft?: Partial<StakingFormFields>) => void;
12+
}
13+
14+
const { StateProvider, useState: useFormPersistenceState } =
15+
createStateUtils<FormPersistenceContext>({
16+
btcStakeDraft: undefined,
17+
babyStakeDraft: undefined,
18+
setBtcStakeDraft: () => {},
19+
setBabyStakeDraft: () => {},
20+
});
21+
22+
export function FormPersistenceState({ children }: PropsWithChildren) {
23+
const [btcStakeDraft, setBtcStakeDraft] = useState<
24+
Partial<MultistakingFormFields> | undefined
25+
>(undefined);
26+
const [babyStakeDraft, setBabyStakeDraft] = useState<
27+
Partial<StakingFormFields> | undefined
28+
>(undefined);
29+
30+
const context = useMemo(
31+
() => ({
32+
btcStakeDraft,
33+
babyStakeDraft,
34+
setBtcStakeDraft,
35+
setBabyStakeDraft,
36+
}),
37+
[btcStakeDraft, babyStakeDraft, setBtcStakeDraft, setBabyStakeDraft],
38+
);
39+
40+
return <StateProvider value={context}>{children}</StateProvider>;
41+
}
42+
43+
export { useFormPersistenceState };

services/simple-staking/src/ui/common/state/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ import { BalanceState } from "./BalanceState";
1818
import { DelegationState } from "./DelegationState";
1919
import { DelegationV2State } from "./DelegationV2State";
2020
import { FinalityProviderState } from "./FinalityProviderState";
21+
import { FormPersistenceState } from "./FormPersistenceState";
2122
import { RewardsState } from "./RewardState";
2223
import { StakingExpansionState } from "./StakingExpansionState";
2324
import { StakingState } from "./StakingState";
2425

2526
// The order of the states is important for the state provider
2627
const STATE_LIST = [
28+
FormPersistenceState,
2729
DelegationState,
2830
DelegationV2State,
2931
FinalityProviderState,

0 commit comments

Comments
 (0)