Skip to content

Commit

Permalink
feat(idea/frontend): program balance (#1604)
Browse files Browse the repository at this point in the history
  • Loading branch information
nikitayutanov authored Jul 30, 2024
1 parent d527cbb commit 893ae57
Show file tree
Hide file tree
Showing 23 changed files with 247 additions and 197 deletions.
3 changes: 3 additions & 0 deletions idea/frontend/src/features/balance/assets/plus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions idea/frontend/src/features/balance/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { TransferBalance } from './ui';
import { TransferBalance, ProgramBalance } from './ui';

export { TransferBalance };
export { TransferBalance, ProgramBalance };
10 changes: 10 additions & 0 deletions idea/frontend/src/features/balance/ui/balance/balance.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.value {
font-family: Kanit;
font-weight: 600;
}

.unit {
font-family: Kanit;
font-weight: 300;
color: rgba(255, 255, 255, 0.7);
}
26 changes: 26 additions & 0 deletions idea/frontend/src/features/balance/ui/balance/balance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useBalanceFormat } from '@gear-js/react-hooks';
import { Balance as BalanceType } from '@polkadot/types/interfaces';

import styles from './balance.module.scss';

type Props = {
value: BalanceType | undefined;
};

function Balance({ value }: Props) {
const { getFormattedBalance } = useBalanceFormat();

if (!value) return null;

const balance = getFormattedBalance(value);

return (
<p>
<span className={styles.value}>{balance.value}</span>
&nbsp;
<span className={styles.unit}>{balance.unit}</span>
</p>
);
}

export { Balance };
3 changes: 3 additions & 0 deletions idea/frontend/src/features/balance/ui/balance/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Balance } from './balance';

export { Balance };
3 changes: 2 additions & 1 deletion idea/frontend/src/features/balance/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TransferBalance } from './transfer-balance';
import { ProgramBalance } from './program-balance';

export { TransferBalance };
export { TransferBalance, ProgramBalance };
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ProgramBalance } from './program-balance';

export { ProgramBalance };
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.balance {
display: flex;
align-items: center;
gap: 8px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { HexString } from '@gear-js/api';
import { useAccount, useBalance } from '@gear-js/react-hooks';
import { Button } from '@gear-js/ui';

import { useModalState } from '@/hooks';

import PlusSVG from '../../assets/plus.svg?react';
import { Balance } from '../balance';
import { TransferBalanceModal } from '../transfer-balance-modal';
import styles from './program-balance.module.scss';

type Props = {
id: HexString;
};

function ProgramBalance({ id }: Props) {
const { account } = useAccount();
const { balance } = useBalance(id);

const [isModalOpen, openModal, closeModal] = useModalState();

return (
<>
<div className={styles.balance}>
<Balance value={balance} />

{account && <Button icon={PlusSVG} color="transparent" onClick={openModal} />}
</div>

{isModalOpen && <TransferBalanceModal defaultAddress={id} close={closeModal} />}
</>
);
}

export { ProgramBalance };
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,16 @@
grid-template-columns: 1fr 1fr;
gap: 32px;
}

.balance {
margin-top: 8px;

display: flex;
gap: 8px;

.text {
font-family: Kanit;
font-weight: 300;
color: rgba(255, 255, 255, 0.7);
}
}
Original file line number Diff line number Diff line change
@@ -1,66 +1,97 @@
import { decodeAddress } from '@gear-js/api';
import { useAccount, useBalanceFormat } from '@gear-js/react-hooks';
import { Button, Checkbox, Modal } from '@gear-js/ui';
import { yupResolver } from '@hookform/resolvers/yup';
import { useAccount, useApi, useDeriveBalancesAll } from '@gear-js/react-hooks';
import { Button, Modal } from '@gear-js/ui';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import * as yup from 'yup';
import { z } from 'zod';

import CloseSVG from '@/shared/assets/images/actions/close.svg?react';
import { Input, ValueField } from '@/shared/ui/form';
import { isAccountAddressValid } from '@/shared/helpers';
import { useBalanceTransfer } from '@/hooks';
import { Checkbox, Input, ValueField } from '@/shared/ui';
import { useBalanceSchema, useLoading, useSignAndSend } from '@/hooks';
import { ACCOUNT_ADDRESS_SCHEMA } from '@/shared/config';

import SubmitSVG from '../../assets/submit.svg?react';
import { Balance } from '../balance';
import styles from './transfer-balance-modal.module.scss';

const defaultValues = { address: '', value: '', keepAlive: true };
const FIELD_NAME = {
ADDRESS: 'address',
VALUE: 'value',
KEEP_ALIVE: 'keepAlive',
} as const;

const schema = yup.object().shape({
address: yup
.string()
.test('is-address-valid', 'Invalid address', isAccountAddressValid)
.required('This field is required'),
value: yup.string().required('This field is required'),
keepAlive: yup.boolean().required(),
});
const DEFAULT_VALUES = {
[FIELD_NAME.VALUE]: '',
[FIELD_NAME.KEEP_ALIVE]: true,
};

function useSchema() {
const balanceSchema = useBalanceSchema();

const resolver = yupResolver(schema);
return z.object({
// address can be a program id too, but we don't need to validate it's existence. account address schema should do the work
[FIELD_NAME.ADDRESS]: ACCOUNT_ADDRESS_SCHEMA,
[FIELD_NAME.VALUE]: balanceSchema,
[FIELD_NAME.KEEP_ALIVE]: z.boolean(),
});
}

type Props = {
defaultAddress?: string;
close: () => void;
};

const TransferBalanceModal = ({ close }: Props) => {
const TransferBalanceModal = ({ defaultAddress = '', close }: Props) => {
const { api, isApiReady } = useApi();
const { account } = useAccount();
const { getChainBalanceValue } = useBalanceFormat();
const transferBalance = useBalanceTransfer();
const balance = useDeriveBalancesAll(account?.address);

const [isLoading, enableLoading, disableLoading] = useLoading();
const signAndSend = useSignAndSend();

const schema = useSchema();

const form = useForm({
defaultValues: { ...DEFAULT_VALUES, [FIELD_NAME.ADDRESS]: defaultAddress },
resolver: zodResolver(schema),
});

const methods = useForm({ defaultValues, resolver });
const { register } = methods;
const handleSubmit = form.handleSubmit(({ address, value, keepAlive }) => {
if (!isApiReady) throw new Error('API is not initialized');

const handleSubmit = ({ address, value, keepAlive }: typeof defaultValues) => {
if (!account) return;
enableLoading();

const chainValue = getChainBalanceValue(value).toFixed();
const signSource = account.meta.source;
const onSuccess = close;
const onError = disableLoading;

transferBalance(account.address, decodeAddress(address), chainValue, { keepAlive, signSource, onSuccess });
};
const extrinsic = keepAlive
? api.tx.balances.transferKeepAlive(address, value)
: api.tx.balances.transferAllowDeath(address, value);

signAndSend(extrinsic, 'Transfer', { onSuccess, onError });
});

return (
<Modal heading="Transfer Balance" size="large" close={close}>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleSubmit)}>
<FormProvider {...form}>
<form onSubmit={handleSubmit}>
<div className={styles.inputs}>
<Input name="address" label="Address" direction="y" block />
<ValueField name="value" label="Value:" direction="y" block />
<Checkbox label="Keep Alive" {...register('keepAlive')} />
<Input name={FIELD_NAME.ADDRESS} label="Address" direction="y" block />

<div>
<ValueField name={FIELD_NAME.VALUE} label="Value" direction="y" block />

<div className={styles.balance}>
<p className={styles.text}>Your transferrable balance:</p>
<Balance value={balance?.availableBalance} />
</div>
</div>

<Checkbox name={FIELD_NAME.KEEP_ALIVE} label="Keep Alive" />
</div>

<div className={styles.buttons}>
<Button type="submit" text="Send" size="large" icon={SubmitSVG} />
<Button icon={CloseSVG} color="light" size="large" text="Close" onClick={close} />
<Button type="submit" icon={SubmitSVG} text="Send" size="large" disabled={isLoading} />
<Button icon={CloseSVG} text="Close" size="large" color="light" onClick={close} />
</div>
</form>
</FormProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ import styles from './program-table.module.scss';
type Props = {
program: Program | LocalProgram | undefined;
isProgramReady: boolean;
renderBalance: () => JSX.Element;
};

const ProgramTable = ({ program, isProgramReady }: Props) => {
const ProgramTable = ({ program, isProgramReady, renderBalance }: Props) => {
const { codeId } = program || {};
const blockId = program && 'blockHash' in program ? program.blockHash : undefined;

Expand All @@ -39,19 +40,15 @@ const ProgramTable = ({ program, isProgramReady }: Props) => {
return (
<div className={styles.table}>
<Table>
<TableRow name="Program Balance">{renderBalance()}</TableRow>

<TableRow name="Program ID">
<IdBlock id={program.id} size="big" />
</TableRow>

<TableRow name="Status">
<BulbBlock size="large" text={PROGRAM_STATUS_NAME[program.status]} status={getBulbStatus(program.status)} />
</TableRow>

{'timestamp' in program && (
<TableRow name="Created at">
<TimestampBlock size="large" timestamp={program.timestamp} />
</TableRow>
)}
</Table>

<Table>
Expand All @@ -66,6 +63,12 @@ const ProgramTable = ({ program, isProgramReady }: Props) => {
<IdBlock id={blockId} to={generatePath(absoluteRoutes.block, { blockId })} size="big" />
</TableRow>
)}

{'timestamp' in program && (
<TableRow name="Created at">
<TimestampBlock size="large" timestamp={program.timestamp} />
</TableRow>
)}
</Table>
</div>
);
Expand Down
14 changes: 1 addition & 13 deletions idea/frontend/src/features/voucher/consts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
import { decodeAddress } from '@gear-js/api';
import { z } from 'zod';

import { isAccountAddressValid } from '@/shared/helpers';

import { Values } from './types';

const FIELD_NAME = {
Expand All @@ -29,11 +24,4 @@ const DEFAULT_FILTER_VALUES = {
status: '',
};

const ADDRESS_SCHEMA = z
.string()
.trim()
.min(0)
.refine((value) => isAccountAddressValid(value), 'Invalid address')
.transform((value) => decodeAddress(value));

export { FIELD_NAME, VOUCHER_TYPE, DEFAULT_VALUES, DEFAULT_FILTER_VALUES, ADDRESS_SCHEMA };
export { FIELD_NAME, VOUCHER_TYPE, DEFAULT_VALUES, DEFAULT_FILTER_VALUES };
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { useLoading, useBalanceSchema, useSignAndSend } from '@/hooks';
import ApplySVG from '@/shared/assets/images/actions/apply.svg?react';
import CloseSVG from '@/shared/assets/images/actions/close.svg?react';
import { Input, ValueField } from '@/shared/ui';
import { ACCOUNT_ADDRESS_SCHEMA } from '@/shared/config';

import { ADDRESS_SCHEMA, DEFAULT_VALUES, FIELD_NAME, VOUCHER_TYPE } from '../../consts';
import { DEFAULT_VALUES, FIELD_NAME, VOUCHER_TYPE } from '../../consts';
import { useDurationSchema, useVoucherType } from '../../hooks';
import { Values } from '../../types';
import { DurationForm } from '../duration-form';
Expand All @@ -34,7 +35,7 @@ const IssueVoucherModal = ({ programId, close, onSubmit = () => {} }: Props) =>
const durationSchema = useDurationSchema();

const schema = z.object({
[FIELD_NAME.ACCOUNT_ADDRESS]: ADDRESS_SCHEMA,
[FIELD_NAME.ACCOUNT_ADDRESS]: ACCOUNT_ADDRESS_SCHEMA,
[FIELD_NAME.VALUE]: balanceSchema,
[FIELD_NAME.DURATION]: durationSchema,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import ApplySVG from '@/shared/assets/images/actions/apply.svg?react';
import CloseSVG from '@/shared/assets/images/actions/close.svg?react';
import { asOptionalField } from '@/shared/helpers';
import { Input, ValueField } from '@/shared/ui';
import { ACCOUNT_ADDRESS_SCHEMA } from '@/shared/config';

import { ADDRESS_SCHEMA, DEFAULT_VALUES, FIELD_NAME } from '../../consts';
import { DEFAULT_VALUES, FIELD_NAME } from '../../consts';
import { useDurationSchema } from '../../hooks';
import { Values, Voucher } from '../../types';
import { DurationForm } from '../duration-form';
Expand All @@ -35,7 +36,7 @@ const UpdateVoucherModal = ({ voucher, close, onSubmit }: Props) => {
const durationSchema = useDurationSchema();

const schema = z.object({
[FIELD_NAME.ACCOUNT_ADDRESS]: asOptionalField(ADDRESS_SCHEMA),
[FIELD_NAME.ACCOUNT_ADDRESS]: asOptionalField(ACCOUNT_ADDRESS_SCHEMA),
[FIELD_NAME.VALUE]: asOptionalField(balanceSchema),
[FIELD_NAME.DURATION]: asOptionalField(durationSchema),
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { decodeAddress } from '@gear-js/api';
import { useAccount, useAlert } from '@gear-js/react-hooks';
import { Button, Modal, buttonStyles } from '@gear-js/ui';
import type { InjectedAccountWithMeta } from '@polkadot/extension-inject/types';
Expand Down Expand Up @@ -81,11 +80,6 @@ const AccountsModal = ({ close }: Props) => {
handleAccountClick(_account);
};

const handleCopy = () => {
const decodedAddress = decodeAddress(address);
copyToClipboard(decodedAddress, alert);
};

const accountBtnClasses = cx(
buttonStyles.large,
styles.accountButton,
Expand All @@ -95,7 +89,7 @@ const AccountsModal = ({ close }: Props) => {
return (
<li key={address} className={styles.accountItem}>
<AccountButton name={meta.name} address={address} className={accountBtnClasses} onClick={handleClick} />
<Button icon={CopyKeySVG} color="transparent" onClick={handleCopy} />
<Button icon={CopyKeySVG} color="transparent" onClick={() => copyToClipboard(address, alert)} />
</li>
);
});
Expand Down
Loading

0 comments on commit 893ae57

Please sign in to comment.