feat: postage stamps support (#115)
* chore: release 0.3.0 * feat: added postage stamp table to list all stamps * feat: postage stamp modal to purchase stamps * feat: postage stamps provider * chore: added formik * chore: proper form state handling * chore: revert accidental release inclusion * chore: polishing identified when developing the upload functionality Co-authored-by: bee-worker <70210089+bee-worker@users.noreply.github.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { Table, TableBody, TableCell, TableContainer, TableRow, TableHead, Paper
|
||||
|
||||
import ClipboardCopy from '../../components/ClipboardCopy'
|
||||
import CashoutModal from '../../components/CashoutModal'
|
||||
import PeerDetailDrawer from './PeerDetail'
|
||||
import PeerDetailDrawer from '../../components/PeerDetail'
|
||||
import { Accounting } from '../../hooks/accounting'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { Typography } from '@material-ui/core'
|
||||
|
||||
function truncStringPortion(str: string, firstCharCount = 10, endCharCount = 10) {
|
||||
return `${str.substring(0, firstCharCount)}...${str.substring(str.length - endCharCount, str.length)}`
|
||||
}
|
||||
|
||||
interface Props {
|
||||
peerId: string
|
||||
}
|
||||
|
||||
export default function PeerDetail(props: Props): ReactElement {
|
||||
return (
|
||||
<Typography
|
||||
variant="button"
|
||||
style={{
|
||||
fontFamily: 'monospace, monospace',
|
||||
}}
|
||||
>
|
||||
{truncStringPortion(props.peerId)}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { ReactElement, useContext } from 'react'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import DialogActions from '@material-ui/core/DialogActions'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import DialogContentText from '@material-ui/core/DialogContentText'
|
||||
import CircularProgress from '@material-ui/core/CircularProgress'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import BigNumber from 'bignumber.js'
|
||||
import { FormikHelpers, Form, Field, Formik } from 'formik'
|
||||
import { TextField } from 'formik-material-ui'
|
||||
import { beeApi } from '../../services/bee'
|
||||
import { Context } from '../../providers/Stamps'
|
||||
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
|
||||
|
||||
interface FormValues {
|
||||
depth?: string
|
||||
amount?: string
|
||||
label?: string
|
||||
}
|
||||
type FormErrors = Partial<FormValues>
|
||||
const initialFormValues: FormValues = {
|
||||
depth: '',
|
||||
amount: '',
|
||||
label: '',
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
wrapper: {
|
||||
margin: theme.spacing(1),
|
||||
position: 'relative',
|
||||
},
|
||||
field: {
|
||||
marginTop: theme.spacing(1),
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
buttonProgress: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
marginTop: -12,
|
||||
marginBottom: -12,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export default function FormDialog({ label }: Props): ReactElement {
|
||||
const classes = useStyles()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const { refresh } = useContext(Context)
|
||||
const handleClickOpen = () => setOpen(true)
|
||||
const handleClose = () => setOpen(false)
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialFormValues}
|
||||
onSubmit={async (values: FormValues, actions: FormikHelpers<FormValues>) => {
|
||||
try {
|
||||
if (!values.depth) return
|
||||
|
||||
const amount = BigInt(values.amount)
|
||||
const depth = Number.parseInt(values.depth)
|
||||
const options = values.label ? { label: values.label } : undefined
|
||||
await beeApi.stamps.buyPostageStamp(amount, depth, options)
|
||||
actions.resetForm()
|
||||
await refresh()
|
||||
handleClose()
|
||||
} catch (e) {
|
||||
// TODO: trigger notification with notistack
|
||||
console.error(`${e.message}`) // eslint-disable-line
|
||||
actions.setSubmitting(false)
|
||||
}
|
||||
}}
|
||||
validate={(values: FormValues) => {
|
||||
const errors: FormErrors = {}
|
||||
|
||||
// Depth
|
||||
if (!values.depth) errors.depth = 'Required field'
|
||||
else {
|
||||
const depth = new BigNumber(values.depth)
|
||||
|
||||
if (!depth.isInteger()) errors.depth = 'Depth must be an integer'
|
||||
else if (depth.isLessThan(16)) errors.depth = 'Minimal depth is 16'
|
||||
else if (depth.isGreaterThan(255)) errors.depth = 'Depth has to be at most 255'
|
||||
}
|
||||
|
||||
// Amount
|
||||
if (!values.amount) errors.amount = 'Required field'
|
||||
else {
|
||||
const amount = new BigNumber(values.amount)
|
||||
|
||||
if (!amount.isInteger()) errors.amount = 'Amount must be an integer'
|
||||
else if (amount.isLessThanOrEqualTo(0)) errors.amount = 'Amount must be greater than 0'
|
||||
}
|
||||
|
||||
// Label
|
||||
if (values.label && !/^[0-9a-z]*$/i.test(values.label)) errors.label = 'Label must be an alphanumeric string'
|
||||
|
||||
return errors
|
||||
}}
|
||||
>
|
||||
{({ submitForm, isValid, isSubmitting }) => (
|
||||
<Form>
|
||||
<Button variant="outlined" color="primary" onClick={handleClickOpen}>
|
||||
{label || 'Buy Postage Stamp'}
|
||||
{isSubmitting && <CircularProgress size={24} className={classes.buttonProgress} />}
|
||||
</Button>
|
||||
<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog-title">
|
||||
<DialogTitle id="form-dialog-title">Purchase new postage stamp</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Provide the depth, amount and optionally the label of the postage stamp. Please refer to the{' '}
|
||||
<a href="https://docs.ethswarm.org/docs/access-the-swarm/keep-your-data-alive" target="blank">
|
||||
official bee docs
|
||||
</a>{' '}
|
||||
to understand these values.
|
||||
</DialogContentText>
|
||||
<Field
|
||||
component={TextField}
|
||||
required
|
||||
name="depth"
|
||||
autoFocus
|
||||
label="Depth"
|
||||
fullWidth
|
||||
className={classes.field}
|
||||
/>
|
||||
<Field component={TextField} required name="amount" label="Amount" fullWidth className={classes.field} />
|
||||
<Field component={TextField} name="label" label="Label" fullWidth className={classes.field} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<div className={classes.wrapper}>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={isSubmitting || !isValid}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
onClick={submitForm}
|
||||
>
|
||||
Create
|
||||
{isSubmitting && <CircularProgress size={24} className={classes.buttonProgress} />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { Table, TableBody, TableCell, TableContainer, TableRow, TableHead, Paper } from '@material-ui/core'
|
||||
|
||||
import ClipboardCopy from '../../components/ClipboardCopy'
|
||||
import PeerDetailDrawer from '../../components/PeerDetail'
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
table: {
|
||||
minWidth: 650,
|
||||
},
|
||||
values: {
|
||||
textAlign: 'right',
|
||||
fontFamily: 'monospace, monospace',
|
||||
},
|
||||
})
|
||||
interface Props {
|
||||
postageStamps: PostageBatch[] | null
|
||||
}
|
||||
|
||||
function StampsTable({ postageStamps }: Props): ReactElement | null {
|
||||
if (postageStamps === null) return null
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table className={classes.table} size="small" aria-label="Balances Table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Batch ID</TableCell>
|
||||
<TableCell align="right">Utilization</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{postageStamps.map(({ batchID, utilization }) => (
|
||||
<TableRow key={batchID}>
|
||||
<TableCell>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<small>
|
||||
<PeerDetailDrawer peerId={batchID} />
|
||||
</small>
|
||||
<ClipboardCopy value={batchID} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={classes.values}>{utilization}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default StampsTable
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ReactElement, useContext, useEffect } from 'react'
|
||||
import { Theme, createStyles, makeStyles } from '@material-ui/core/styles'
|
||||
import { Container, CircularProgress } from '@material-ui/core'
|
||||
|
||||
import StampsTable from './StampsTable'
|
||||
import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard'
|
||||
import CreatePostageStampModal from './CreatePostageStampModal'
|
||||
import LastUpdate from '../../components/LastUpdate'
|
||||
|
||||
import { useApiHealth, useDebugApiHealth } from '../../hooks/apiHooks'
|
||||
import { Context } from '../../providers/Stamps'
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
rowGap: theme.spacing(2),
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
columnGap: theme.spacing(1),
|
||||
rowGap: theme.spacing(1),
|
||||
flex: '0 1 auto',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export default function Accounting(): ReactElement {
|
||||
const classes = useStyles()
|
||||
|
||||
const { health, isLoadingHealth } = useApiHealth()
|
||||
const { nodeHealth, isLoadingNodeHealth } = useDebugApiHealth()
|
||||
const { stamps, isLoading, error, lastUpdate, start, stop } = useContext(Context)
|
||||
useEffect(() => {
|
||||
start()
|
||||
|
||||
return () => stop()
|
||||
}, [])
|
||||
|
||||
if (isLoadingHealth || isLoadingNodeHealth) {
|
||||
return (
|
||||
<Container style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
if (nodeHealth?.status !== 'ok' || !health) return <TroubleshootConnectionCard />
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{error && (
|
||||
<Container style={{ textAlign: 'center', padding: '50px' }}>
|
||||
Error loading postage stamps details: {error.message}
|
||||
</Container>
|
||||
)}
|
||||
{!error && (
|
||||
<>
|
||||
<div className={classes.actions}>
|
||||
<CreatePostageStampModal />
|
||||
<LastUpdate date={lastUpdate} />
|
||||
<div style={{ height: '5px' }}>{isLoading && <CircularProgress />}</div>
|
||||
</div>
|
||||
<StampsTable postageStamps={stamps} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user