From f057a4fec6a479a8dfdb26947bb4ad443b74dc8f Mon Sep 17 00:00:00 2001 From: Memas Date: Mon, 17 Oct 2022 13:36:57 +0200 Subject: [PATCH] #86 staking and unstaking (#87) * changed all fake data to go through actions * cleaned up staking-related types * Added links to the UI descriptions to the local readme of the state * removed empty reducers * added staking position listing to validator details * added a comment about the redundancy of actions --- .../NewBondingPosition.components.ts | 17 ++ .../NewBondingPosition/NewBondingPosition.tsx | 150 ++++++++++++++++ .../App/Staking/NewBondingPosition/index.ts | 1 + .../src/App/Staking/Staking.tsx | 94 +++++++++- .../StakingOverview/StakingOverview.tsx | 8 +- .../UnbondPosition.components.ts | 12 ++ .../Staking/UnbondPosition/UnbondPosition.tsx | 163 ++++++++++++++++++ .../src/App/Staking/UnbondPosition/index.ts | 1 + .../ValidatorDetails/ValidatorDetails.tsx | 86 +-------- .../StakingAndGovernance.tsx | 19 +- .../src/components/Modal/Modal.components.ts | 2 +- .../src/components/Modal/Modal.tsx | 1 + .../src/components/Table/Table.components.ts | 1 - .../src/components/Table/types.ts | 2 +- apps/namada-interface/src/slices/README.md | 17 ++ .../slices/StakingAndGovernance/actions.ts | 36 ++++ .../src/slices/StakingAndGovernance/index.ts | 4 + .../slices/StakingAndGovernance/reducers.ts | 32 +++- .../src/slices/StakingAndGovernance/types.ts | 21 ++- apps/namada-interface/src/store/mocks.ts | 2 + 20 files changed, 580 insertions(+), 89 deletions(-) create mode 100644 apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.components.ts create mode 100644 apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.tsx create mode 100644 apps/namada-interface/src/App/Staking/NewBondingPosition/index.ts create mode 100644 apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.components.ts create mode 100644 apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.tsx create mode 100644 apps/namada-interface/src/App/Staking/UnbondPosition/index.ts create mode 100644 apps/namada-interface/src/slices/README.md diff --git a/apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.components.ts b/apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.components.ts new file mode 100644 index 00000000000..a95a41dea64 --- /dev/null +++ b/apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.components.ts @@ -0,0 +1,17 @@ +import styled from "styled-components/macro"; + +export const BondingPositionContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; + width: 100%; + margin: 16px 0 16px; + overflow-y: scroll; + color: ${(props) => props.theme.colors.utility2.main}; +`; + +export const BondingAmountInputContainer = styled.div` + display: flex; + width: 100%; +`; diff --git a/apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.tsx b/apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.tsx new file mode 100644 index 00000000000..f4337c9d40e --- /dev/null +++ b/apps/namada-interface/src/App/Staking/NewBondingPosition/NewBondingPosition.tsx @@ -0,0 +1,150 @@ +import { useState } from "react"; +import { + BondingPositionContainer, + BondingAmountInputContainer, +} from "./NewBondingPosition.components"; +import { Table, TableConfigurations, KeyValueData } from "components/Table"; +import { Button, ButtonVariant } from "components/Button"; +import { + StakingPosition, + ChangeInStakingPosition, +} from "slices/StakingAndGovernance"; + +const REMAINS_BONDED_KEY = "Remains bonded"; + +// configuration for the summary table that represents all the data in this view +const bondingDetailsConfigurations: TableConfigurations = { + rowRenderer: (rowData: KeyValueData) => { + // if this is the last row we style it bold + const styleForRemainsBondedRow = + rowData.key === REMAINS_BONDED_KEY ? { fontWeight: "bold" } : {}; + return ( + <> + + {rowData.key} + + {rowData.value} + + ); + }, + columns: [ + { uuid: "1", columnLabel: "", width: "30%" }, + { uuid: "2", columnLabel: "", width: "70%" }, + ], +}; + +type Props = { + currentBondingPosition: StakingPosition; + // this is how much we have available for bonding + totalFundsToBond: number; + // called when the user confirms bonding + confirmBonding: (changeInStakingPosition: ChangeInStakingPosition) => void; + // called when the user cancels bonding + cancelBonding: () => void; +}; + +// contains everything what the user needs for bonding funds +export const NewBondingPosition = (props: Props): JSX.Element => { + const { + currentBondingPosition, + totalFundsToBond, + confirmBonding, + cancelBonding, + } = props; + const { validatorId, stakedCurrency } = currentBondingPosition; + + // storing the unbonding input value locally here as string + // we threat them as strings except below in validation + // might have to change later to numbers + const [amountToBond, setAmountToBond] = useState(""); + + // unbonding amount and displayed value with a very naive validation + // TODO (https://github.com/anoma/namada-interface/issues/4#issuecomment-1260564499) + // do proper validation as part of input + const bondedAmountAsNumber = Number(currentBondingPosition.stakedAmount); + const amountToBondNumber = Number(amountToBond); + + // if this is the case, we display error message + const isEntryIncorrect = + (amountToBond !== "" && amountToBondNumber <= 0) || + amountToBondNumber > totalFundsToBond || + Number.isNaN(amountToBondNumber); + + // if incorrect or empty value we disable the button + const isEntryIncorrectOrEmpty = isEntryIncorrect || amountToBond === ""; + + // we convey this with an object that can be used + const remainsBondedToDisplay = isEntryIncorrect + ? `The bonding amount can be more than 0 and at most ${totalFundsToBond}` + : `${bondedAmountAsNumber + amountToBondNumber}`; + + // data for the table + const bondingSummary = [ + { + uuid: "1", + key: "Total Funds", + value: `${totalFundsToBond}`, + }, + { + uuid: "2", + key: "Bonded amount", + value: currentBondingPosition.stakedAmount, + }, + { + uuid: "3", + key: "Amount to bond", + value: amountToBond, + hint: "stake", + }, + { + uuid: "4", + key: REMAINS_BONDED_KEY, + value: remainsBondedToDisplay, + }, + ]; + + return ( + + {/* input field */} + + { + setAmountToBond(event.target.value); + }} + /> + + + {/* summary table */} + + + {/* confirmation and cancel */} + + + + ); +}; diff --git a/apps/namada-interface/src/App/Staking/NewBondingPosition/index.ts b/apps/namada-interface/src/App/Staking/NewBondingPosition/index.ts new file mode 100644 index 00000000000..8c0dca9e034 --- /dev/null +++ b/apps/namada-interface/src/App/Staking/NewBondingPosition/index.ts @@ -0,0 +1 @@ +export { NewBondingPosition } from "./NewBondingPosition"; diff --git a/apps/namada-interface/src/App/Staking/Staking.tsx b/apps/namada-interface/src/App/Staking/Staking.tsx index 52e86105024..200da834716 100644 --- a/apps/namada-interface/src/App/Staking/Staking.tsx +++ b/apps/namada-interface/src/App/Staking/Staking.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { Routes, Route, useNavigate, useLocation } from "react-router-dom"; import { MainContainerNavigation } from "App/StakingAndGovernance/MainContainerNavigation"; +import { Modal } from "components/Modal"; import { StakingContainer } from "./Staking.components"; import { StakingOverview } from "./StakingOverview"; import { ValidatorDetails } from "./ValidatorDetails"; @@ -10,7 +11,10 @@ import { Validator, MyValidators, StakingPosition, + ChangeInStakingPosition, } from "slices/StakingAndGovernance"; +import { NewBondingPosition } from "./NewBondingPosition"; +import { UnbondPosition } from "./UnbondPosition"; const initialTitle = "Staking"; @@ -48,18 +52,45 @@ type Props = { myValidators: MyValidators[]; myStakingPositions: StakingPosition[]; selectedValidatorId: string | undefined; - onInitCallback: () => void; // will be called at first load, triggers fetching + // will be called at first load, parent decides what happens + onInitCallback: () => void; fetchValidatorDetails: (validatorId: string) => void; + postNewBonding: (changeInStakingPosition: ChangeInStakingPosition) => void; + postNewUnbonding: (changeInStakingPosition: ChangeInStakingPosition) => void; }; +// in this view we can be in one of these states at any given time +export enum ModalState { + None, + NewBonding, + Unbond, +} + +// TODO: these should go to Modal component +export enum ModalOnRequestCloseType { + Confirm, + Cancel, +} + +// This is the parent view for all staking related views. Most of the +// staking specific functions are defined here and passed down as props. +// This contains the main vies in staking: +// * StakingOverview - displaying an overview of the users staking and validators +// * ValidatorDetails - as the name says +// * NewStakingStakingPosition - rendered in modal on top of other content +// this is for creating new staking positions +// * UnstakePositions - rendered in modal on top of other content, for unstaking export const Staking = (props: Props): JSX.Element => { const [breadcrumb, setBreadcrumb] = useState([initialTitle]); + const [modalState, setModalState] = useState(ModalState.None); const location = useLocation(); const navigate = useNavigate(); const { onInitCallback, fetchValidatorDetails, + postNewBonding, + postNewUnbonding, myBalances, validators, myValidators, @@ -68,9 +99,10 @@ export const Staking = (props: Props): JSX.Element => { } = props; // these 2 are needed for validator details - const stakingPositionsWithSelectedValidator = myStakingPositions.filter( - (validator) => validator.validatorId === selectedValidatorId - ); + const stakingPositionsWithSelectedValidator = + myStakingPositions.filter( + (validator) => validator.validatorId === selectedValidatorId + ); const selectedValidator = validators.find( (validator) => validator.uuid === selectedValidatorId @@ -113,6 +145,29 @@ export const Staking = (props: Props): JSX.Element => { fetchValidatorDetails(validatorId); }; + // callbacks for the bonding and unbonding views + const confirmBonding = ( + changeInStakingPosition: ChangeInStakingPosition + ): void => { + setModalState(ModalState.None); + postNewBonding(changeInStakingPosition); + }; + + const cancelBonding = (): void => { + setModalState(ModalState.None); + }; + + const confirmUnbonding = ( + changeInStakingPosition: ChangeInStakingPosition + ): void => { + setModalState(ModalState.None); + postNewUnbonding(changeInStakingPosition); + }; + + const cancelUnbonding = (): void => { + setModalState(ModalState.None); + }; + return ( { setBreadcrumb([initialTitle]); }} /> + {/* modal for bonding */} + { + cancelBonding(); + }} + > + + + + {/* modal for unbonding */} + { + cancelUnbonding(); + }} + > + + { stakingPositionsWithSelectedValidator={ stakingPositionsWithSelectedValidator } + setModalState={setModalState} /> } /> diff --git a/apps/namada-interface/src/App/Staking/StakingOverview/StakingOverview.tsx b/apps/namada-interface/src/App/Staking/StakingOverview/StakingOverview.tsx index 1392d873e16..77aa7d6e257 100644 --- a/apps/namada-interface/src/App/Staking/StakingOverview/StakingOverview.tsx +++ b/apps/namada-interface/src/App/Staking/StakingOverview/StakingOverview.tsx @@ -1,4 +1,3 @@ -import { StakingOverviewContainer } from "./StakingOverview.components"; import { Table, TableLink, @@ -10,6 +9,7 @@ import { Validator, MyValidators, } from "slices/StakingAndGovernance"; +import { StakingOverviewContainer } from "./StakingOverview.components"; // My Balances table row renderer and configuration const myBalancesRowRenderer = (myBalanceEntry: MyBalanceEntry): JSX.Element => { @@ -135,6 +135,12 @@ type Props = { myValidators: MyValidators[]; }; +// This is the default view for the staking. it displays all the relevant +// staking information of the user and allows unstake the active staking +// positions directly from here. +// * Unstaking happens by calling a callback that triggers a modal +// view in the parent +// * user can also navigate to sibling view for validator details export const StakingOverview = (props: Props): JSX.Element => { const { navigateToValidatorDetails, myBalances, validators, myValidators } = props; diff --git a/apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.components.ts b/apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.components.ts new file mode 100644 index 00000000000..fd9a2066155 --- /dev/null +++ b/apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.components.ts @@ -0,0 +1,12 @@ +import styled from "styled-components/macro"; + +export const UnstakePositionContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; + width: 100%; + margin: 16px 0 16px; + overflow-y: scroll; + color: ${(props) => props.theme.colors.utility2.main}; +`; diff --git a/apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.tsx b/apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.tsx new file mode 100644 index 00000000000..809bd4d8b6d --- /dev/null +++ b/apps/namada-interface/src/App/Staking/UnbondPosition/UnbondPosition.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { UnstakePositionContainer } from "./UnbondPosition.components"; +import { Table, TableConfigurations, KeyValueData } from "components/Table"; +import { Button, ButtonVariant } from "components/Button"; +import { + StakingPosition, + ChangeInStakingPosition, +} from "slices/StakingAndGovernance"; + +// keys for the table that we want to act upon in table configuration +const AMOUNT_TO_UNBOND_KEY = "Amount to unbond"; +const REMAINS_BONDED_KEY = "Remains bonded"; + +// contains the callback to change unbonding amount in summary table +type UnbondingCallbacks = { + setAmountToUnstake: React.Dispatch>; +}; + +// configuration for the summary table +const unbondingDetailsConfigurations: TableConfigurations< + KeyValueData, + UnbondingCallbacks +> = { + rowRenderer: (rowData: KeyValueData, callbacks?: UnbondingCallbacks) => { + const styleForRemainsBondedRow = + rowData.key === REMAINS_BONDED_KEY ? { fontWeight: "bold" } : {}; + const valueOrInput = + rowData.key === AMOUNT_TO_UNBOND_KEY ? ( + + ) : ( + + ); + + return ( + <> + + {valueOrInput} + + ); + }, + columns: [ + { uuid: "1", columnLabel: "", width: "30%" }, + { uuid: "2", columnLabel: "", width: "70%" }, + ], +}; + +type Props = { + currentBondingPosition: StakingPosition; + confirmUnbonding: (changeInStakingPosition: ChangeInStakingPosition) => void; + cancelUnbonding: () => void; +}; + +// contains data and controls to unbond +export const UnbondPosition = (props: Props): JSX.Element => { + const { currentBondingPosition, confirmUnbonding, cancelUnbonding } = props; + const { validatorId, stakedCurrency } = currentBondingPosition; + + // storing the bonding amount input value locally here as string + // we threat them as strings except below in validation + // might have to change later to numbers + const [amountToBondOrUnbond, setAmountToBondOrUnbond] = useState(""); + + // configurations for the summary table + const unbondingDetailsConfigurationsWithCallbacks: TableConfigurations< + KeyValueData, + UnbondingCallbacks + > = { + ...unbondingDetailsConfigurations, + callbacks: { + setAmountToUnstake: setAmountToBondOrUnbond, + }, + }; + + // unbonding amount and displayed value with a very naive validation + // TODO (https://github.com/anoma/namada-interface/issues/4#issuecomment-1260564499) + // do proper validation as part of input + const bondedAmountAsNumber = Number(currentBondingPosition.stakedAmount); + const amountToUnstakeAsNumber = Number(amountToBondOrUnbond); + const remainsBonded = bondedAmountAsNumber - amountToUnstakeAsNumber; + + // if the input value is incorrect we display an error + const isEntryIncorrect = + (amountToBondOrUnbond !== "" && amountToUnstakeAsNumber <= 0) || + remainsBonded < 0 || + Number.isNaN(amountToUnstakeAsNumber); + + // if the input value is incorrect or empty we disable the confirm button + const isEntryIncorrectOrEmpty = + isEntryIncorrect || amountToBondOrUnbond === ""; + + // we convey this with an object that can be used + const remainsBondedToDisplay = isEntryIncorrect + ? `The unbonding amount can be more than 0 and at most ${bondedAmountAsNumber}` + : `${remainsBonded}`; + + // This is the value that we pass to be dispatch to the action + const delta = amountToUnstakeAsNumber * -1; + const deltaAsString = `${delta}`; + + // data for the summary table + const unbondingSummary = [ + { + uuid: "1", + key: "Bonded amount", + value: currentBondingPosition.stakedAmount, + }, + { + uuid: "2", + key: AMOUNT_TO_UNBOND_KEY, + value: "", + hint: "stake", + }, + { + uuid: "3", + key: "Remains bonded", + value: remainsBondedToDisplay, + }, + ]; + + return ( + + {/* summary table */} +
+ { + callbacks?.setAmountToUnstake(event.target.value); + }} + /> + {rowData.value} + {rowData.key} +
+ + {/* confirm and cancel buttons */} + + + + ); +}; diff --git a/apps/namada-interface/src/App/Staking/UnbondPosition/index.ts b/apps/namada-interface/src/App/Staking/UnbondPosition/index.ts new file mode 100644 index 00000000000..332c250d54e --- /dev/null +++ b/apps/namada-interface/src/App/Staking/UnbondPosition/index.ts @@ -0,0 +1 @@ +export { UnbondPosition } from "./UnbondPosition"; diff --git a/apps/namada-interface/src/App/Staking/ValidatorDetails/ValidatorDetails.tsx b/apps/namada-interface/src/App/Staking/ValidatorDetails/ValidatorDetails.tsx index c5e8dab4564..4260e17de64 100644 --- a/apps/namada-interface/src/App/Staking/ValidatorDetails/ValidatorDetails.tsx +++ b/apps/namada-interface/src/App/Staking/ValidatorDetails/ValidatorDetails.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { ValidatorDetailsContainer, StakeButtonContainer, @@ -10,8 +9,8 @@ import { TableLink, } from "components/Table"; import { Button, ButtonVariant } from "components/Button"; -import { Modal } from "components/Modal"; import { Validator, StakingPosition } from "slices/StakingAndGovernance"; +import { ModalState } from "../Staking"; const validatorDetailsConfigurations: TableConfigurations = { @@ -54,7 +53,7 @@ const getMyStakingWithValidatorConfigurations = ( {stakingPosition.stakedAmount}{" "} { - setModalState(ModalState.Unstake); + setModalState(ModalState.Unbond); }} > unstake @@ -76,6 +75,7 @@ const getMyStakingWithValidatorConfigurations = ( type Props = { validator?: Validator; stakingPositionsWithSelectedValidator?: StakingPosition[]; + setModalState: React.Dispatch>; }; // this turns the Validator object to rows that are passed to the table @@ -98,86 +98,18 @@ const validatorToDataRows = ( ]; }; -type StakingViewProps = { - onRequestClose: (modalOnRequestCloseType: ModalOnRequestCloseType) => void; -}; - -const StakingView = (props: StakingViewProps): JSX.Element => { - const { onRequestClose } = props; - return ( - <> - - - - ); -}; - -enum ModalState { - None, - Stake, - Unstake, -} - -enum ModalOnRequestCloseType { - Confirm, - Cancel, -} - export const ValidatorDetails = (props: Props): JSX.Element => { - const { validator, stakingPositionsWithSelectedValidator = [] } = props; + const { + validator, + setModalState, + stakingPositionsWithSelectedValidator = [], + } = props; const validatorDetailsData = validatorToDataRows(validator); - - const [modalState, setModalState] = useState(ModalState.None); - - const onRequestCloseStakingModal = ( - modalOnRequestCloseType: ModalOnRequestCloseType - ): void => { - switch (modalOnRequestCloseType) { - case ModalOnRequestCloseType.Confirm: { - setModalState(ModalState.None); - break; - } - case ModalOnRequestCloseType.Cancel: { - setModalState(ModalState.None); - break; - } - } - }; - const myStakingWithValidatorConfigurations = getMyStakingWithValidatorConfigurations(setModalState); return ( - { - setModalState(ModalState.None); - }} - > - - - { - setModalState(ModalState.None); - }} - > - -
{