Skip to content

Commit

Permalink
feat: bookmark CSVs (#608)
Browse files Browse the repository at this point in the history
Add the option to bookmark the current CSV content and to load previously bookmarked files.

Bookmarks are stored by chainId and the user can give a name to each of the transfers.

Bookmarks also get persisted into local storage such that they are persisted between page loads.
  • Loading branch information
schmanu authored Jul 10, 2024
1 parent 893080d commit fad91e9
Show file tree
Hide file tree
Showing 15 changed files with 629 additions and 48 deletions.
5 changes: 3 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Box, Button, Card, CircularProgress, Grid, Typography, useTheme } from "@mui/material";
import { useSafeAppsSDK } from "@safe-global/safe-apps-react-sdk";
import { BaseTransaction, GatewayTransactionDetails } from "@safe-global/safe-apps-sdk";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Unsubscribe } from "redux";

Expand Down Expand Up @@ -29,6 +29,7 @@ const App: React.FC = () => {
const { sdk, safe } = useSafeAppsSDK();

const { messages } = useSelector((state: RootState) => state.messages);
const errorMessages = useMemo(() => messages.filter((msg) => msg.severity !== "success"), [messages]);
const { transfers, parsing } = useSelector((state: RootState) => state.csvEditor);

const [pendingTx, setPendingTx] = useState<GatewayTransactionDetails>();
Expand Down Expand Up @@ -125,7 +126,7 @@ const App: React.FC = () => {
disabled={parsing || transfers.length + collectibleTransfers.length === 0}
>
{parsing && <CircularProgress size={24} color="primary" />}
{messages.length === 0 ? "Submit" : "Submit with errors"}
{errorMessages.length === 0 ? "Submit" : "Submit with errors"}
</Button>
<MessageSnackbar />
</Box>
Expand Down
2 changes: 2 additions & 0 deletions src/AppInitializer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useSafeAppsSDK } from "@safe-global/safe-apps-react-sdk";

import { useLoadAssets, useLoadCollectibles } from "./hooks/useBalances";
import { useLoadChains } from "./hooks/useChains";
import { useHydrateBookmarks } from "./hooks/useHydrateBookmarks";
import { useLoadAddressbook } from "./hooks/useLoadAddressbook";

export const AppInitializer = () => {
Expand All @@ -10,5 +11,6 @@ export const AppInitializer = () => {
useLoadAssets(safe);
useLoadCollectibles(safe);
useLoadAddressbook();
useHydrateBookmarks();
return <></>;
};
2 changes: 1 addition & 1 deletion src/components/CSVEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const CSVEditor = (props: CSVEditorProps): JSX.Element => {
theme="tomorrow"
width={"100%"}
mode={"text"}
minLines={6}
minLines={8}
maxLines={20}
setOptions={{
firstLineNumber: 0,
Expand Down
22 changes: 17 additions & 5 deletions src/components/CSVForm.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Box, Grid, Typography } from "@mui/material";
import { Box, Grid, Stack, Typography } from "@mui/material";

import { CSVEditor } from "./CSVEditor";
import { CSVUpload } from "./CSVUpload";
import { GenerateTransfersMenu } from "./GenerateTransfersMenu";
import { AddBookmark } from "./bookmarks/AddBookmark";
import { BookmarkLibrary } from "./bookmarks/BookmarkLibrary";

export interface CSVFormProps {}

export const CSVForm = (props: CSVFormProps): JSX.Element => {
return (
<>
<Stack spacing={2}>
<Box display="flex" mb={3} flexDirection="column" alignItems="center" gap={1}>
<Typography variant="h6" fontWeight={700}>
Upload, edit or paste your asset transfer CSV
Expand All @@ -17,16 +19,26 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => {
(token_type,token_address,receiver,amount,id)
</Typography>
</Box>
<CSVEditor />
<Grid direction="row" container alignItems="start">
<Grid item xs={12} md={11}>
<CSVEditor />
</Grid>
<Grid item xs={12} md={1}>
<Stack direction={{ md: "column", xs: "row" }} spacing={2} p={{ md: 2, xs: "16px 0px" }}>
<BookmarkLibrary />
<AddBookmark />
</Stack>
</Grid>
</Grid>

<Grid mt={3} gap={2} container direction="row">
<Grid gap={2} container direction="row">
<Grid item md={6} display="flex" alignItems="flex-start">
<CSVUpload />
</Grid>
<Grid item md={5} display="flex" alignItems="flex-start">
<GenerateTransfersMenu />
</Grid>
</Grid>
</>
</Stack>
);
};
25 changes: 22 additions & 3 deletions src/components/MessageSnackbar.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
import CloseIcon from "@mui/icons-material/Close";
import ErrorIcon from "@mui/icons-material/Error";
import { Alert, Box, IconButton, LinearProgress, Snackbar } from "@mui/material";
import { useEffect, useState } from "react";
import { selectMessages } from "src/stores/slices/messageSlice";
import { useEffect, useMemo, useState } from "react";
import { Message, selectMessages } from "src/stores/slices/messageSlice";
import { useAppSelector } from "src/stores/store";

const HIDE_TIME = 7_000;

const higherSeverity = (a: Message["severity"], b: Message["severity"]) => {
if (a === b) {
return a;
}
if (a === "error" || b === "error") {
return "error";
}
if (a === "warning" || b === "warning") {
return "warning";
}
return "success";
};

export const MessageSnackbar = () => {
const messages = useAppSelector(selectMessages);

const [open, setOpen] = useState(false);
const [timeLeft, setTimeLeft] = useState(0);

const maxSeverity = useMemo(
() =>
messages.messages.reduce((prev, curr) => higherSeverity(prev, curr.severity), "success" as Message["severity"]),
[messages],
);

useEffect(() => {
let timer: NodeJS.Timer | undefined = undefined;
if (messages.messages.length > 0) {
Expand Down Expand Up @@ -43,7 +62,7 @@ export const MessageSnackbar = () => {
<>
{messages.messages.length > 0 ? (
<div>
<IconButton size="small" aria-label="close" color="error" disabled={open} onClick={onOpen}>
<IconButton size="small" aria-label="close" color={maxSeverity} disabled={open} onClick={onOpen}>
<ErrorIcon fontSize="medium" />
</IconButton>
</div>
Expand Down
93 changes: 93 additions & 0 deletions src/components/bookmarks/AddBookmark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { BookmarkAdd } from "@mui/icons-material";
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
import { useState } from "react";
import { useSelector } from "react-redux";
import { useCsvContent } from "src/hooks/useCsvContent";
import { useCurrentChain } from "src/hooks/useCurrentChain";
import { addBookmark } from "src/stores/slices/bookmarkSlice";
import { setMessages } from "src/stores/slices/messageSlice";
import { RootState, useAppDispatch } from "src/stores/store";

import { SquareButton } from "../common/SquareButton";

const AddBookmarkModal = ({ open, onClose }: { open: boolean; onClose: () => void }) => {
const currentContent = useCsvContent();
const dispatch = useAppDispatch();
const currentChain = useCurrentChain();
const [name, setName] = useState("");
const { transfers } = useSelector((state: RootState) => state.csvEditor);

const onSubmit = () => {
if (!currentChain) {
return;
}
dispatch(
addBookmark({
chainId: currentChain.chainID.toString(),
name: name,
csvContent: currentContent,
transfers: transfers.length,
}),
);
dispatch(
setMessages([
{
message: "Successfully stored transfer",
severity: "success",
},
]),
);
onClose();
};

return (
<Dialog fullWidth open={open} onClose={onClose}>
<DialogTitle>Add new bookmark</DialogTitle>
<DialogContent>
<Typography>
This action lets you choose a name for the current CSV content and store it to reuse the same transfer data
later.
</Typography>

<Typography mt={2} variant="h6" fontWeight={700}>
Preview:
</Typography>
<Box
sx={{
overflow: "auto",
width: "100%",
padding: 1,
backgroundColor: ({ palette }) => palette.background.main,
borderRadius: "6px",
border: ({ palette }) => `1px solid ${palette.border.main}`,
}}
>
<pre>{currentContent}</pre>
</Box>
<TextField
sx={{ mt: 3 }}
name="name"
label="Name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button variant="contained" onClick={onSubmit}>
Submit
</Button>
</DialogActions>
</Dialog>
);
};

export const AddBookmark = () => {
const [open, setOpen] = useState(false);

return (
<>
<SquareButton icon={<BookmarkAdd />} title="Save to bookmark library" onClick={() => setOpen(true)} />
<AddBookmarkModal open={open} onClose={() => setOpen(false)} />
</>
);
};
147 changes: 147 additions & 0 deletions src/components/bookmarks/BookmarkLibrary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { Bookmarks } from "@mui/icons-material";
import {
Dialog,
DialogTitle,
DialogContent,
Typography,
Box,
DialogActions,
Button,
Select,
FormControl,
InputLabel,
MenuItem,
} from "@mui/material";
import { useState } from "react";
import { useCurrentChain } from "src/hooks/useCurrentChain";
import { selectBookmarksByChain, setBookmarksByChain } from "src/stores/slices/bookmarkSlice";
import { updateCsvContent } from "src/stores/slices/csvEditorSlice";
import { setMessages } from "src/stores/slices/messageSlice";
import { useAppDispatch, RootState, useAppSelector } from "src/stores/store";

import { SquareButton } from "../common/SquareButton";

const LoadBookmarkModal = ({ open, onClose }: { open: boolean; onClose: () => void }) => {
const dispatch = useAppDispatch();
const currentChain = useCurrentChain();
const bookmarks = useAppSelector((state: RootState) =>
selectBookmarksByChain(state, currentChain?.chainID.toString() ?? "-1"),
);

const [selectedIdx, setSelectedIdx] = useState(0);

const isDisabled = !bookmarks || bookmarks.length === 0;

const onLoad = () => {
if (!bookmarks || !bookmarks[selectedIdx]) {
return;
}

dispatch(updateCsvContent({ csvContent: bookmarks[selectedIdx].csvContent }));

dispatch(
setMessages([
{
message: "Successfully loaded transfer from library",
severity: "success",
},
]),
);
onClose();
};

const onDelete = () => {
if (!bookmarks || !bookmarks[selectedIdx] || !currentChain) {
return;
}

const updatedBookmarks = bookmarks.slice(0, selectedIdx).concat(bookmarks.slice(selectedIdx + 1));
dispatch(
setBookmarksByChain({
bookmarks: updatedBookmarks,
chainId: currentChain.chainID.toString(),
}),
);
dispatch(
setMessages([
{
message: "Successfully loaded transfer from library",
severity: "success",
},
]),
);
setSelectedIdx(0);
};

return (
<Dialog fullWidth open={open} onClose={onClose}>
<DialogTitle>Load bookmarked file</DialogTitle>
<DialogContent>
<Typography mb={3}>Select one of your stored transfers and restore it.</Typography>

{bookmarks && bookmarks.length > 0 ? (
<>
<FormControl fullWidth>
<InputLabel id="library-item-select">Select Bookmark</InputLabel>
<Select
labelId="library-item-select"
id="library-item"
value={selectedIdx}
fullWidth
label="Select Bookmark"
variant="outlined"
onChange={(event) => setSelectedIdx(Number(event.target.value))}
>
{bookmarks?.map((bookmark, bookmarkIdx) => (
<MenuItem value={bookmarkIdx}>{bookmark.name}</MenuItem>
))}
</Select>
</FormControl>

<Typography mt={2} variant="h6" fontWeight={700}>
Preview:
</Typography>
<Box
sx={{
overflow: "auto",
width: "100%",
padding: 1,
backgroundColor: ({ palette }) => palette.background.main,
borderRadius: "6px",
border: ({ palette }) => `1px solid ${palette.border.main}`,
}}
>
<pre>{bookmarks?.[selectedIdx]?.csvContent}</pre>
</Box>
</>
) : (
<Typography variant="body2">No bookmarks stored</Typography>
)}
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button variant="contained" onClick={onLoad} disabled={isDisabled}>
Load Content
</Button>
<Button variant="danger" onClick={onDelete} disabled={isDisabled}>
Delete
</Button>
</DialogActions>
</Dialog>
);
};

export const BookmarkLibrary = () => {
const [open, setOpen] = useState(false);
return (
<>
<SquareButton
icon={<Bookmarks />}
title="Open bookmark library"
onClick={() => {
setOpen(true);
}}
/>
<LoadBookmarkModal open={open} onClose={() => setOpen(false)} />
</>
);
};
Loading

0 comments on commit fad91e9

Please sign in to comment.