Skip to content

Commit

Permalink
UI Rework (#23)
Browse files Browse the repository at this point in the history
* Reworks the UI of the app

- adds TextArea to review and/or edit the CSV Data
- uses gnosis-safe-components for a consistent UX
- handles csv parse errors

Noteworthy:
- updates gnosis-safe-components to 0.6.0
- adds materal-ui/lab dependency

* refactors components and loading of token list

* moves components out of App.tsx into own files
* introduces hook to fetch token list as this wont change after each update

* fixes linter issues

Co-authored-by: schmanu <[email protected]>
  • Loading branch information
schmanu and schmanu authored May 5, 2021
1 parent 3f11ad3 commit e4f4edb
Show file tree
Hide file tree
Showing 6 changed files with 395 additions and 246 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"private": true,
"dependencies": {
"@gnosis.pm/safe-apps-sdk": "^0.3.1",
"@gnosis.pm/safe-react-components": "^0.2.0",
"@gnosis.pm/safe-react-components": "^0.6.0",
"@material-ui/core": "^4.11.0",
"@material-ui/lab": "^4.0.0-alpha.58",
"@openzeppelin/contracts": "^3.3.0",
"@rmeissner/safe-apps-react-sdk": "0.4.0",
"@testing-library/jest-dom": "^5.11.4",
Expand Down
176 changes: 67 additions & 109 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,27 @@
import { SafeInfo, Transaction } from "@gnosis.pm/safe-apps-sdk";
import React, { useCallback, useState } from "react";
import BigNumber from "bignumber.js";
import styled from "styled-components";
import { Button, Loader, Title } from "@gnosis.pm/safe-react-components";
import { useSafe } from "@rmeissner/safe-apps-react-sdk";
import { parseString } from "@fast-csv/parse";
import IERC20 from "@openzeppelin/contracts/build/contracts/IERC20.json";
import { AbiItem } from "web3-utils";
import { utils } from "ethers";

import { initWeb3 } from "./connect";
import { fetchTokenList, TokenMap } from "./tokenList";
import { TokenMap, useTokenList } from "./tokenList";
import { Header } from "./components/Header";
import { Payment, CSVForm } from "./components/CSVForm";
import { Loader, Text } from "@gnosis.pm/safe-react-components";
import styled from "styled-components";

const TEN = new BigNumber(10);

const Container = styled.form`
margin-bottom: 2rem;
width: 100%;
max-width: 480px;
display: grid;
grid-template-columns: 1fr;
grid-column-gap: 1rem;
grid-row-gap: 1rem;
`;

interface SnakePayment {
type SnakePayment = {
receiver: string;
amount: string;
token_address: string;
decimals: number;
}
interface Payment {
receiver: string;
amount: BigNumber;
tokenAddress: string;
decimals: number;
}
};

function buildTransfers(
safeInfo: SafeInfo,
Expand Down Expand Up @@ -72,45 +57,48 @@ function buildTransfers(

const App: React.FC = () => {
const safe = useSafe();
const { tokenList, isLoading } = useTokenList();
const [submitting, setSubmitting] = useState(false);
const [transferContent, setTransferContent] = useState<Payment[]>([]);
const [tokenList, setTokenList] = useState<TokenMap>();

const onChangeHandler = async (event: any) => {
console.log("Received Filename", event.target.files[0].name);

const reader = new FileReader();
const filePromise = new Promise<SnakePayment[]>((resolve, reject) => {
reader.onload = function (evt) {
if (!evt.target) {
return;
}
// Parse CSV
const results: any[] = [];
parseString(evt.target.result as string, { headers: true })
.on("data", (data) => results.push(data))
.on("end", () => resolve(results))
.on("error", (error) => reject(error));
};
const [csvText, setCsvText] = useState<string>(
"token_address,receiver,amount"
);
const [lastError, setLastError] = useState<any>();

const onChangeTextHandler = async (csvText: string) => {
console.log("Changed CSV", csvText);
setCsvText(csvText);
// Parse CSV
const parsePromise = new Promise<SnakePayment[]>((resolve, reject) => {
const results: any[] = [];
parseString(csvText, { headers: true })
.validate(
(data) =>
(data.token_address === "" ||
data.token_address === null ||
utils.isAddress(data.token_address)) &&
utils.isAddress(data.receiver) &&
Math.sign(data.amount) >= 0
)
.on("data", (data) => results.push(data))
.on("end", () => resolve(results))
.on("error", (error) => reject(error));
});

reader.readAsText(event.target.files[0]);
const parsedFile = await filePromise;

const transfers: Payment[] = parsedFile
.map(({ amount, receiver, token_address, decimals }) => ({
amount: new BigNumber(amount),
receiver,
tokenAddress:
token_address === "" ? null : utils.getAddress(token_address),
decimals,
}))
.filter((payment) => !payment.amount.isZero());

// TODO - could reduce token list by filtering on uniqe items from transfers
const tokens = await fetchTokenList(safe.info.network);
setTokenList(tokens);
setTransferContent(transfers);
parsePromise
.then((rows) => {
const transfers: Payment[] = rows
.map(({ amount, receiver, token_address, decimals }) => ({
amount: new BigNumber(amount),
receiver,
tokenAddress:
token_address === "" ? null : utils.getAddress(token_address),
decimals,
}))
.filter((payment) => !payment.amount.isZero());
setTransferContent(transfers);
})
.catch((reason: any) => setLastError(reason));
};

const submitTx = useCallback(async () => {
Expand All @@ -128,66 +116,36 @@ const App: React.FC = () => {
}
setSubmitting(false);
}, [safe, transferContent, tokenList]);

return (
<Container>
<Title size="md">CSV Airdrop</Title>
<input type="file" name="file" onChange={onChangeHandler} />
<a href="./sample.csv" download>
Sample Transfer File
</a>
<table>
<thead>
<tr>
<td>Token</td>
<td>Receiver</td>
<td>Amount</td>
</tr>
</thead>
<tbody>
{transferContent.map((row, index) => {
return (
<tr key={index}>
<td>
<img /* TODO - alt doesn't really work here */
alt={""}
src={tokenList.get(row.tokenAddress)?.logoURI}
style={{
maxWidth: 20,
marginRight: 3,
}}
/>{" "}
{tokenList.get(row.tokenAddress)?.symbol || row.tokenAddress}
</td>
{/* TODO - get account names from Safe's Address Book */}
<td>{row.receiver}</td>
<td>{row.amount.toString()}</td>
</tr>
);
})}
</tbody>
</table>
{submitting ? (
<Header lastError={lastError} onCloseError={() => setLastError(null)} />
{isLoading ? (
<>
<Loader size="md" />
<br />
<Button
size="lg"
color="secondary"
onClick={() => {
setSubmitting(false);
}}
>
Cancel
</Button>
<Loader size={"lg"} />
<Text size={"lg"}>Loading Tokenlist...</Text>
</>
) : (
<Button size="lg" color="primary" onClick={submitTx}>
Submit
</Button>
<CSVForm
csvText={csvText}
onAbortSubmit={() => setSubmitting(false)}
submitting={submitting}
transferContent={transferContent}
onSubmit={submitTx}
onChange={onChangeTextHandler}
tokenList={tokenList}
/>
)}
</Container>
);
};

const Container = styled.div`
margin-left: 8px;
display: flex;
flex-direction: column;
flex: 1;
justify-content: left;
width: 100%;
`;

export default App;
164 changes: 164 additions & 0 deletions src/components/CSVForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import React from "react";
import styled from "styled-components";

import MuiAlert from "@material-ui/lab/Alert";
import {
Card,
Text,
Button,
Link,
Table,
Loader,
} from "@gnosis.pm/safe-react-components";
import { TextField } from "@material-ui/core";
import BigNumber from "bignumber.js";
import { TokenMap } from "src/tokenList";

export function Alert(props) {
return <MuiAlert elevation={6} variant="filled" {...props} />;
}

const Form = styled.div`
flex: 1;
flex-direction: column;
display: flex;
justify-content: space-around;
gap: 8px;
`;

export interface CSVFormProps {
onChange: (transactionCSV: string) => void;
onSubmit: () => void;
csvText: string;
transferContent: Payment[];
tokenList: TokenMap;
submitting: boolean;
onAbortSubmit: () => void;
}

export interface Payment {
receiver: string;
amount: BigNumber;
tokenAddress: string;
decimals: number;
}

export const CSVForm = (props: CSVFormProps) => {
const tokenList = props.tokenList;
const extractTokenElement = (payment: Payment) => {
return (
<div>
<img /* TODO - alt doesn't really work here */
alt={""}
src={tokenList.get(payment.tokenAddress)?.logoURI}
style={{
maxWidth: 20,
marginRight: 3,
}}
/>{" "}
{tokenList.get(payment.tokenAddress)?.symbol || payment.tokenAddress}
</div>
);
};

const onChangeFileHandler = async (event: any) => {
console.log("Received Filename", event.target.files[0].name);

const reader = new FileReader();
reader.onload = function (evt) {
if (!evt.target) {
return;
}
props.onChange(evt.target.result as string);
};
reader.readAsText(event.target.files[0]);
};
return (
<Card>
<Form>
<Text size="md">
Upload, edit or paste your transfer CSV. <br />
(token_address,receiver,amount)
</Text>
<TextField
variant="outlined"
label="CSV"
onChange={(event) => props.onChange(event.target.value)}
value={props.csvText}
multiline
rows={6}
/>
<div>
<input
accept="*.csv"
id="csvUploadButton"
type="file"
name="file"
onChange={onChangeFileHandler}
style={{ display: "none" }}
/>
<label htmlFor="csvUploadButton">
<Button
size="md"
variant="contained"
color="primary"
component="span"
>
Upload CSV
</Button>
</label>
</div>
<div>
<Link href="./sample.csv" download>
Sample Transfer File
</Link>
</div>
{props.transferContent.length > 0 && (
<>
<div>
<Table
headers={[
{ id: "token", label: "Token" },
{ id: "receiver", label: "Receiver" },
{ id: "amount", label: "Amount" },
]}
rows={props.transferContent.map((row, index) => {
return {
id: "" + index,
cells: [
{ id: "token", content: extractTokenElement(row) },
{ id: "receiver", content: row.receiver },
{ id: "amount", content: row.amount.toString() },
],
};
})}
/>
</div>
{props.submitting ? (
<>
<Loader size="md" />
<br />
<Button
size="lg"
color="secondary"
onClick={props.onAbortSubmit}
>
Cancel
</Button>
</>
) : (
<Button
style={{ alignSelf: "center" }}
size="lg"
color="primary"
onClick={props.onSubmit}
>
Submit
</Button>
)}
</>
)}
</Form>
</Card>
);
};
Loading

0 comments on commit e4f4edb

Please sign in to comment.