Skip to content

Commit

Permalink
Create v2 send/receive pages
Browse files Browse the repository at this point in the history
  • Loading branch information
jessepinho committed Aug 12, 2024
1 parent 4b6b2ed commit 92b7f98
Show file tree
Hide file tree
Showing 9 changed files with 315 additions and 13 deletions.
2 changes: 1 addition & 1 deletion apps/minifront/src/components/send/send-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { InputBlock } from '../../shared/input-block';
import { useMemo } from 'react';
import { penumbraAddrValidation } from '../helpers';
import InputToken from '../../shared/input-token';
import { useRefreshFee } from './use-refresh-fee';
import { GasFee } from '../../shared/gas-fee';
import { useBalancesResponses, useStakingTokenMetadata } from '../../../state/shared';
import { NonNativeFeeWarning } from '../../shared/non-native-fee-warning';
import { transferableBalancesResponsesSelector } from '../../../state/send/helpers';
import { useRefreshFee } from '../../v2/transfer-layout/send-page/use-refresh-fee';

export const SendForm = () => {
const stakingTokenMetadata = useStakingTokenMetadata();
Expand Down
17 changes: 17 additions & 0 deletions apps/minifront/src/components/v2/root-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { PagePath } from '../metadata/paths';
import { DashboardLayout } from './dashboard-layout';
import { AssetsPage } from './dashboard-layout/assets-page';
import { TransactionsPage } from './dashboard-layout/transactions-page';
import { TransferLayout } from './transfer-layout';
import { SendPage } from './transfer-layout/send-page';
import { ReceivePage } from './transfer-layout/receive-page';

/** @todo: Delete this helper once we switch over to the v2 layout. */
const temporarilyPrefixPathsWithV2 = (routes: RouteObject[]): RouteObject[] =>
Expand Down Expand Up @@ -47,6 +50,20 @@ export const routes: RouteObject[] = temporarilyPrefixPathsWithV2([
},
],
},
{
path: PagePath.SEND,
element: <TransferLayout />,
children: [
{
index: true,
element: <SendPage />,
},
{
path: PagePath.RECEIVE,
element: <ReceivePage />,
},
],
},
],
},
]);
40 changes: 40 additions & 0 deletions apps/minifront/src/components/v2/transfer-layout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Card } from '@repo/ui/Card';
import { Outlet, useNavigate } from 'react-router-dom';
import { Grid } from '@repo/ui/Grid';
import { Tabs } from '@repo/ui/Tabs';
import { usePagePath } from '../../../fetchers/page-path';
import { PagePath } from '../../metadata/paths';

/** @todo: Remove this function and its uses after we switch to v2 layout */
const v2PathPrefix = (path: string) => `/v2${path}`;

const TABS_OPTIONS = [
{ label: 'Send', value: v2PathPrefix(PagePath.SEND) },
{ label: 'Receive', value: v2PathPrefix(PagePath.RECEIVE) },
];

export const TransferLayout = () => {
const pagePath = usePagePath();
const navigate = useNavigate();

return (
<Grid container>
<Grid mobile={0} tablet={2} desktop={3} xl={4} />

<Grid tablet={8} desktop={6} xl={4}>
<Card title='Transfer Assets'>
<Tabs
value={v2PathPrefix(pagePath)}
onChange={value => navigate(value)}
options={TABS_OPTIONS}
actionType='accent'
/>

<Outlet />
</Card>
</Grid>

<Grid mobile={0} tablet={2} desktop={3} xl={4} />
</Grid>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { AccountSelector } from '@repo/ui/AccountSelector';
import { Card } from '@repo/ui/Card';
import { FormField } from '@repo/ui/FormField';
import { getAddrByIndex } from '../../../../fetchers/address';

export const ReceivePage = () => {
return (
<Card.Stack>
<Card.Section>
<FormField label='Address'>
<AccountSelector getAddressByIndex={getAddrByIndex} />
</FormField>
</Card.Section>
</Card.Stack>
);
};
124 changes: 124 additions & 0 deletions apps/minifront/src/components/v2/transfer-layout/send-page/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Card } from '@repo/ui/Card';
import { FormField } from '@repo/ui/FormField';
import { SegmentedControl } from '@repo/ui/SegmentedControl';
import { TextInput } from '@repo/ui/TextInput';
import { AllSlices } from '../../../../state';
import { sendValidationErrors } from '../../../../state/send';
import { FeeTier_Tier } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1/fee_pb';
import { Button } from '@repo/ui/Button';
import { ArrowUpFromDot } from 'lucide-react';
import { useMemo } from 'react';
import { useStoreShallow } from '../../../../utils/use-store-shallow';
import { useRefreshFee } from './use-refresh-fee';

const sendPageSelector = (state: AllSlices) => ({
selection: state.send.selection,
amount: state.send.amount,
recipient: state.send.recipient,
memo: state.send.memo,
fee: state.send.fee,
feeTier: state.send.feeTier,
assetFeeMetadata: state.send.assetFeeMetadata,
setAmount: state.send.setAmount,
setSelection: state.send.setSelection,
setRecipient: state.send.setRecipient,
setFeeTier: state.send.setFeeTier,
setMemo: state.send.setMemo,
sendTx: state.send.sendTx,
txInProgress: state.send.txInProgress,
});

const FEE_TIER_OPTIONS = [
{
label: 'Low',
value: FeeTier_Tier.LOW,
},
{
label: 'Medium',
value: FeeTier_Tier.MEDIUM,
},
{
label: 'High',
value: FeeTier_Tier.HIGH,
},
];

export const SendPage = () => {
const {
selection,
amount,
recipient,
memo,
fee,
feeTier,
assetFeeMetadata,
setAmount,
setSelection,
setRecipient,
setFeeTier,
setMemo,
sendTx,
txInProgress,
} = useStoreShallow(sendPageSelector);

useRefreshFee();

const validationErrors = useMemo(() => {
return sendValidationErrors(selection, amount, recipient);
}, [selection, amount, recipient]);

const submitButtonDisabled = useMemo(
() =>
!Number(amount) ||
!recipient ||
!!Object.values(validationErrors).find(Boolean) ||
txInProgress ||
!selection,
[amount, recipient, validationErrors, txInProgress, selection],
);

return (
<>
<Card.Stack>
<Card.Section>
<FormField label="Recipient's address">
<TextInput value={recipient} onChange={setRecipient} placeholder='penumbra1abc123...' />
</FormField>
</Card.Section>

<Card.Section>
<FormField label='Amount' helperText='#0: 123,456.789'>
<TextInput
type='number'
value={amount}
onChange={setAmount}
placeholder='Amount to send...'
min={0}
/>
</FormField>
</Card.Section>

<Card.Section>
<FormField label='Fee Tier'>
<SegmentedControl value={feeTier} onChange={setFeeTier} options={FEE_TIER_OPTIONS} />
</FormField>
</Card.Section>

<Card.Section>
<FormField label='Memo'>
<TextInput value={memo} onChange={setMemo} placeholder='Optional Message...' />
</FormField>
</Card.Section>
</Card.Stack>

<Button
type='submit'
icon={ArrowUpFromDot}
actionType='accent'
disabled={submitButtonDisabled}
>
Send
</Button>
</>
);
};
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { useCallback, useEffect, useRef } from 'react';
import { sendSelector } from '../../../state/send';
import { useStore } from '../../../state';
import { AllSlices } from '../../../../state';
import { useStoreShallow } from '../../../../utils/use-store-shallow';

const DEBOUNCE_MS = 500;

const useRefreshFeeSelector = (state: AllSlices) => ({
amount: state.send.amount,
feeTier: state.send.feeTier,
recipient: state.send.recipient,
selection: state.send.selection,
refreshFee: state.send.refreshFee,
});

/**
* Refreshes the fee in the state when the amount, recipient, selection, or memo
* changes.
*/
export const useRefreshFee = () => {
const { amount, feeTier, recipient, selection, refreshFee } = useStore(sendSelector);
const { amount, feeTier, recipient, selection, refreshFee } =
useStoreShallow(useRefreshFeeSelector);
const timeoutId = useRef<number | null>(null);

const debouncedRefreshFee = useCallback(() => {
Expand Down
42 changes: 42 additions & 0 deletions packages/ui/src/SegmentedControl/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,46 @@ describe('<SegmentedControl />', () => {

expect(onChange).toHaveBeenCalledWith('two');
});

describe('when the options have non-string values', () => {
const valueOne = { toString: () => 'one' };
const valueTwo = { toString: () => 'two' };
const valueThree = { toString: () => 'three' };

const options = [
{ value: valueOne, label: 'One' },
{ value: valueTwo, label: 'Two' },
{ value: valueThree, label: 'Three' },
];

it('calls the `onClick` handler with the value of the clicked option', () => {
const { getByText } = render(
<SegmentedControl value={valueOne} options={options} onChange={onChange} />,
{ wrapper: PenumbraUIProvider },
);
fireEvent.click(getByText('Two', { selector: ':not([aria-hidden])' }));

expect(onChange).toHaveBeenCalledWith(valueTwo);
});

describe("when the options' `.toString()` methods return non-unique values", () => {
const valueOne = { toString: () => 'one' };
const valueTwo = { toString: () => 'two' };
const valueTwoAgain = { toString: () => 'two' };

const options = [
{ value: valueOne, label: 'One' },
{ value: valueTwo, label: 'Two' },
{ value: valueTwoAgain, label: 'Two again' },
];

it('throws', () => {
expect(() =>
render(<SegmentedControl value={valueOne} options={options} onChange={onChange} />, {
wrapper: PenumbraUIProvider,
}),
).toThrow('The value options passed to `<SegmentedControl />` are not unique.');
});
});
});
});
63 changes: 54 additions & 9 deletions packages/ui/src/SegmentedControl/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Density } from '../types/Density';
import { useDensity } from '../hooks/useDensity';
import * as RadixRadioGroup from '@radix-ui/react-radio-group';
import { useDisabled } from '../hooks/useDisabled';
import { ToStringable } from '../utils/ToStringable';
import { useEffect } from 'react';

const Root = styled.div`
display: flex;
Expand Down Expand Up @@ -34,17 +36,44 @@ const Segment = styled.button<{
padding-right: ${props => props.theme.spacing(props.$density === 'sparse' ? 4 : 2)};
`;

export interface Option {
value: string;
/**
* Radix's `<RadioGroup />` component only accepts strings for its values, but
* we don't want to enforce that in `<SegmentedControl />`. Instead, we allow
* options to be passed whose values extend `ToStringable` (i.e., they have a
* `.toString()` method). Then, when a specific option is selected and passed to
* `onChange()`, we need to map from the string value back to the original value
* passed in the options array.
*
* To make sure this works as expected, we need to assert that each option
* value's `.toString()` method returns a unique value. That way, we can avoid a
* situation where, e.g., all the options' values return `[object Object]`, and
* the wrong object is passed to `onChange`.
*/
const assertUniqueOptions = (options: Option<ToStringable>[]) => {
const existingOptions = new Set<string>();

options.forEach(option => {
if (existingOptions.has(option.value.toString())) {
throw new Error(
'The value options passed to `<SegmentedControl />` are not unique. Please check that the result of calling `.toString()` on each of the options passed to `<SegmentedControl />` is unique.',
);
}

existingOptions.add(option.value.toString());
});
};

export interface Option<ValueType extends ToStringable> {
value: ValueType;
label: string;
/** Whether this individual option should be disabled. */
disabled?: boolean;
}

export interface SegmentedControlProps {
value: string;
onChange: (value: string) => void;
options: Option[];
export interface SegmentedControlProps<ValueType extends ToStringable> {
value: ValueType;
onChange: (value: ValueType) => void;
options: Option<ValueType>[];
/**
* Whether this entire control should be disabled. Note that single options
* can be disabled individually by setting the `disabled` property for that
Expand Down Expand Up @@ -74,15 +103,31 @@ export interface SegmentedControlProps {
* />
* ```
*/
export const SegmentedControl = ({ value, onChange, options, disabled }: SegmentedControlProps) => {
export const SegmentedControl = <ValueType extends ToStringable>({
value,
onChange,
options,
disabled,
}: SegmentedControlProps<ValueType>) => {
const density = useDensity();
disabled = useDisabled(disabled);

useEffect(() => assertUniqueOptions(options), [options]);

const handleChange = (value: string) => {
const matchingOption = options.find(option => option.value.toString() === value)!;
onChange(matchingOption.value);
};

return (
<RadixRadioGroup.Root asChild value={value} onValueChange={onChange}>
<RadixRadioGroup.Root asChild value={value.toString()} onValueChange={handleChange}>
<Root>
{options.map(option => (
<RadixRadioGroup.Item asChild key={option.value} value={option.value}>
<RadixRadioGroup.Item
asChild
key={option.value.toString()}
value={option.value.toString()}
>
<Segment
onClick={() => onChange(option.value)}
$getBorderRadius={theme => theme.borderRadius.full}
Expand Down
Loading

0 comments on commit 92b7f98

Please sign in to comment.