Files
dydx-v4-web/src/views/forms/TradeForm.tsx
T
Jared Vu 2bf9e66da5 Fix inputs for comma locales (#171)
* Fix for comma decimal separator locales

* Accept tickSizeDecimals 0

* Use FloatValue as value for Size Input

* Use formattedValue to handle prefix/suffix

* Use MustBigNumber.toString()
2023-12-05 16:22:41 -08:00

469 lines
13 KiB
TypeScript

import { type FormEvent, useState, Ref, useCallback, useEffect } from 'react';
import styled, { AnyStyledComponent, css } from 'styled-components';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import type { NumberFormatValues, SourceInfo } from 'react-number-format';
import { AlertType } from '@/constants/alerts';
import {
ErrorType,
type HumanReadablePlaceOrderPayload,
type Nullable,
TradeInputErrorAction,
TradeInputField,
ValidationError,
} from '@/constants/abacus';
import { ButtonAction, ButtonShape, ButtonSize, ButtonType } from '@/constants/buttons';
import { STRING_KEYS } from '@/constants/localization';
import { USD_DECIMALS } from '@/constants/numbers';
import { InputErrorData, TradeBoxKeys, MobilePlaceOrderSteps } from '@/constants/trade';
import { breakpoints } from '@/styles';
import { useStringGetter, useSubaccount } from '@/hooks';
import { useOnLastOrderIndexed } from '@/hooks/useOnLastOrderIndexed';
import { layoutMixins } from '@/styles/layoutMixins';
import { formMixins } from '@/styles/formMixins';
import { AlertMessage } from '@/components/AlertMessage';
import { Button } from '@/components/Button';
import { FormInput } from '@/components/FormInput';
import { Icon, IconName } from '@/components/Icon';
import { InputType } from '@/components/Input';
import { Tag } from '@/components/Tag';
import { ToggleButton } from '@/components/ToggleButton';
import { WithTooltip } from '@/components/WithTooltip';
import { Orderbook } from '@/views/tables/Orderbook';
import { setTradeFormInputs } from '@/state/inputs';
import { getCurrentInput, getTradeFormInputs, useTradeFormData } from '@/state/inputsSelectors';
import { getCurrentMarketConfig } from '@/state/perpetualsSelectors';
import abacusStateManager from '@/lib/abacus';
import { getTradeInputAlert } from '@/lib/tradeData';
import { AdvancedTradeOptions } from './TradeForm/AdvancedTradeOptions';
import { TradeSizeInputs } from './TradeForm/TradeSizeInputs';
import { TradeSideToggle } from './TradeForm/TradeSideToggle';
import { PlaceOrderButtonAndReceipt } from './TradeForm/PlaceOrderButtonAndReceipt';
import { PositionPreview } from './TradeForm/PositionPreview';
type TradeBoxInputConfig = {
key: TradeBoxKeys;
inputType: InputType;
label: React.ReactNode;
onChange: (values: NumberFormatValues, e: SourceInfo) => void;
ref?: Ref<HTMLInputElement>;
validationConfig?: InputErrorData;
value: string | number;
decimals?: number;
};
type ElementProps = {
currentStep?: MobilePlaceOrderSteps;
setCurrentStep?: (step: MobilePlaceOrderSteps) => void;
onConfirm?: () => void;
};
type StyleProps = {
className?: string;
};
export const TradeForm = ({
currentStep,
setCurrentStep,
onConfirm,
className,
}: ElementProps & StyleProps) => {
const [isPlacingOrder, setIsPlacingOrder] = useState(false);
const [placeOrderError, setPlaceOrderError] = useState<string>();
const [showOrderbook, setShowOrderbook] = useState(false);
const dispatch = useDispatch();
const stringGetter = useStringGetter();
const { placeOrder } = useSubaccount();
const {
price,
size,
summary,
needsLimitPrice,
needsTrailingPercent,
needsTriggerPrice,
executionOptions,
needsGoodUntil,
needsPostOnly,
needsReduceOnly,
timeInForceOptions,
tradeErrors,
} = useTradeFormData();
const currentInput = useSelector(getCurrentInput);
const { tickSizeDecimals, stepSizeDecimals } =
useSelector(getCurrentMarketConfig, shallowEqual) || {};
const tradeFormInputValues = useSelector(getTradeFormInputs, shallowEqual);
const { limitPriceInput, triggerPriceInput, trailingPercentInput } = tradeFormInputValues;
const needsAdvancedOptions =
needsGoodUntil || timeInForceOptions || executionOptions || needsPostOnly || needsReduceOnly;
const tradeFormInputs: TradeBoxInputConfig[] = [];
const isInputFilled =
Object.values(tradeFormInputValues).some((val) => val !== '') ||
Object.values(price || {}).some((val) => !!val) ||
[size?.size, size?.usdcSize, size?.leverage].some((val) => val != null);
const hasInputErrors =
tradeErrors?.some((error: ValidationError) => error.type !== ErrorType.warning) ||
currentInput !== 'trade';
let alertContent;
let alertType = AlertType.Error;
const inputAlert = getTradeInputAlert({
abacusInputErrors: tradeErrors ?? [],
stringGetter,
stepSizeDecimals,
tickSizeDecimals,
});
if (placeOrderError) {
alertContent = placeOrderError;
} else if (inputAlert) {
alertContent = inputAlert?.alertString;
alertType = inputAlert?.type;
}
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
switch (currentStep) {
case MobilePlaceOrderSteps.EditOrder: {
setCurrentStep?.(MobilePlaceOrderSteps.PreviewOrder);
break;
}
case MobilePlaceOrderSteps.PlacingOrder:
case MobilePlaceOrderSteps.Confirmation: {
onConfirm?.();
break;
}
case MobilePlaceOrderSteps.PreviewOrder:
default: {
onPlaceOrder();
setCurrentStep?.(MobilePlaceOrderSteps.PlacingOrder);
break;
}
}
};
const onLastOrderIndexed = useCallback(() => {
if (!currentStep || currentStep === MobilePlaceOrderSteps.PlacingOrder) {
setIsPlacingOrder(false);
abacusStateManager.clearTradeInputValues({ shouldResetSize: true });
setCurrentStep?.(MobilePlaceOrderSteps.Confirmation);
}
}, [currentStep]);
const { setUnIndexedClientId } = useOnLastOrderIndexed({
callback: onLastOrderIndexed,
});
const onPlaceOrder = async () => {
setPlaceOrderError(undefined);
setIsPlacingOrder(true);
await placeOrder({
onError: (errorParams?: { errorStringKey?: Nullable<string> }) => {
setPlaceOrderError(
stringGetter({ key: errorParams?.errorStringKey || STRING_KEYS.SOMETHING_WENT_WRONG })
);
setIsPlacingOrder(false);
},
onSuccess: (placeOrderPayload?: Nullable<HumanReadablePlaceOrderPayload>) => {
setUnIndexedClientId(placeOrderPayload?.clientId);
},
});
};
if (needsTriggerPrice) {
tradeFormInputs.push({
key: TradeBoxKeys.TriggerPrice,
inputType: InputType.Currency,
label: (
<>
<WithTooltip tooltip="trigger-price" side="right">
{stringGetter({ key: STRING_KEYS.TRIGGER_PRICE })}
</WithTooltip>
<Tag>USD</Tag>
</>
),
onChange: ({ value }: NumberFormatValues) => {
dispatch(setTradeFormInputs({ triggerPriceInput: value }));
},
value: triggerPriceInput ?? '',
decimals: tickSizeDecimals ?? USD_DECIMALS,
});
}
if (needsLimitPrice) {
tradeFormInputs.push({
key: TradeBoxKeys.LimitPrice,
inputType: InputType.Currency,
label: (
<>
<WithTooltip tooltip="limit-price" side="right">
{stringGetter({ key: STRING_KEYS.LIMIT_PRICE })}
</WithTooltip>
<Tag>USD</Tag>
</>
),
onChange: ({ value }: NumberFormatValues) => {
dispatch(setTradeFormInputs({ limitPriceInput: value }));
},
value: limitPriceInput,
decimals: tickSizeDecimals ?? USD_DECIMALS,
});
}
if (needsTrailingPercent) {
tradeFormInputs.push({
key: TradeBoxKeys.TrailingPercent,
inputType: InputType.Percent,
label: (
<WithTooltip tooltip="trailing-percent" side="right">
{stringGetter({ key: STRING_KEYS.TRAILING_PERCENT })}
</WithTooltip>
),
onChange: ({ value }: NumberFormatValues) => {
dispatch(setTradeFormInputs({ trailingPercentInput: value }));
},
value: trailingPercentInput ?? '',
});
}
return (
<Styled.TradeForm onSubmit={onSubmit} className={className}>
{currentStep && currentStep !== MobilePlaceOrderSteps.EditOrder ? (
<>
<PositionPreview />
{alertContent && <AlertMessage type={alertType}>{alertContent}</AlertMessage>}
</>
) : (
<>
<Styled.TopActionsRow>
<Styled.OrderbookButtons>
<Styled.OrderbookButton
slotRight={<Icon iconName={IconName.Caret} />}
onPressedChange={setShowOrderbook}
isPressed={showOrderbook}
hidePressedStyle
>
{!showOrderbook && stringGetter({ key: STRING_KEYS.ORDERBOOK })}
</Styled.OrderbookButton>
{/* TODO[TRCL-1411]: add orderbook scale functionality */}
</Styled.OrderbookButtons>
<TradeSideToggle />
</Styled.TopActionsRow>
<Styled.OrderbookAndInputs showOrderbook={showOrderbook}>
{showOrderbook && <Styled.Orderbook maxRowsPerSide={5} selectionBehavior="replace" />}
<Styled.InputsColumn>
{tradeFormInputs.map(
({ key, inputType, label, onChange, validationConfig, value, decimals }) => (
<FormInput
key={key}
id={key}
type={inputType}
label={label}
onChange={onChange}
validationConfig={validationConfig}
value={value}
decimals={decimals}
/>
)
)}
<TradeSizeInputs />
{needsAdvancedOptions && <AdvancedTradeOptions />}
{alertContent && <AlertMessage type={alertType}>{alertContent}</AlertMessage>}
</Styled.InputsColumn>
</Styled.OrderbookAndInputs>
</>
)}
<Styled.Footer>
{isInputFilled && (!currentStep || currentStep === MobilePlaceOrderSteps.EditOrder) && (
<Styled.ButtonRow>
<Button
type={ButtonType.Reset}
action={ButtonAction.Reset}
shape={ButtonShape.Pill}
size={ButtonSize.XSmall}
onClick={() => abacusStateManager.clearTradeInputValues({ shouldResetSize: true })}
>
{stringGetter({ key: STRING_KEYS.CLEAR })}
</Button>
</Styled.ButtonRow>
)}
<PlaceOrderButtonAndReceipt
isLoading={isPlacingOrder}
hasValidationErrors={hasInputErrors}
actionStringKey={inputAlert?.actionStringKey}
summary={summary ?? undefined}
currentStep={currentStep}
showDeposit={inputAlert?.errorAction === TradeInputErrorAction.DEPOSIT}
/>
</Styled.Footer>
</Styled.TradeForm>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.TradeForm = styled.form`
/* Params */
--tradeBox-content-paddingTop: ;
--tradeBox-content-paddingRight: ;
--tradeBox-content-paddingBottom: ;
--tradeBox-content-paddingLeft: ;
/* Rules */
--orderbox-column-width: 140px;
--orderbook-width: calc(var(--orderbox-column-width) + var(--tradeBox-content-paddingLeft));
min-height: 100%;
isolation: isolate;
${layoutMixins.flexColumn}
gap: 0.75rem;
${layoutMixins.stickyArea1}
--stickyArea1-background: var(--color-layer-2);
--stickyArea1-paddingBottom: var(--tradeBox-content-paddingBottom);
padding: var(--tradeBox-content-paddingTop) var(--tradeBox-content-paddingRight)
var(--tradeBox-content-paddingBottom) var(--tradeBox-content-paddingLeft);
@media ${breakpoints.tablet} {
padding-left: 0;
padding-right: 0;
margin-left: var(--tradeBox-content-paddingLeft);
margin-right: var(--tradeBox-content-paddingRight);
&& * {
outline: none !important;
}
}
`;
Styled.TopActionsRow = styled.div`
display: grid;
grid-auto-flow: column;
@media ${breakpoints.tablet} {
grid-auto-columns: var(--orderbox-column-width) 1fr;
gap: var(--form-input-gap);
}
`;
Styled.OrderbookButtons = styled.div`
${layoutMixins.inlineRow}
justify-content: space-between;
gap: 0.25rem;
@media ${breakpoints.notTablet} {
display: none;
}
`;
Styled.OrderbookButton = styled(ToggleButton)`
--button-toggle-off-textColor: var(--color-text-1);
--button-toggle-off-backgroundColor: transparent;
> svg {
color: var(--color-text-0);
height: 0.875em;
width: 0.875em;
transition: 0.2s;
}
&[data-state='on'] {
svg {
rotate: 0.25turn;
}
}
&[data-state='off'] {
svg {
rotate: -0.25turn;
}
}
`;
Styled.OrderbookAndInputs = styled.div<{ showOrderbook: boolean }>`
@media ${breakpoints.tablet} {
display: grid;
align-items: flex-start;
grid-auto-flow: column;
${({ showOrderbook }) =>
showOrderbook
? css`
grid-auto-columns: var(--orderbook-width) 1fr;
gap: var(--form-input-gap);
margin-left: calc(-1 * var(--tradeBox-content-paddingLeft));
`
: css`
grid-auto-columns: 1fr;
gap: 0;
`}
}
`;
Styled.Orderbook = styled(Orderbook)`
width: 100%;
@media ${breakpoints.notTablet} {
display: none;
}
> table {
--tableCell-padding: 0.5em 1em;
scroll-snap-type: none;
thead {
display: none;
}
}
`;
Styled.InputsColumn = styled.div`
${formMixins.inputsColumn}
`;
Styled.ButtonRow = styled.div`
${layoutMixins.row}
justify-self: end;
padding: 0.5rem 0 0.5rem 0;
`;
Styled.Footer = styled.footer`
${formMixins.footer}
--stickyFooterBackdrop-outsetY: var(--tradeBox-content-paddingBottom);
backdrop-filter: none;
${layoutMixins.column}
${layoutMixins.noPointerEvents}
`;