diff --git a/package-lock.json b/package-lock.json index 8be03db..2f2356a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2068,6 +2068,11 @@ "@babel/types": "^7.3.0" } }, + "@types/classnames": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz", + "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw==" + }, "@types/eslint": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.2.6.tgz", @@ -3008,6 +3013,14 @@ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.1.1.tgz", "integrity": "sha512-5Kgy8Cz6LPC9DJcNb3yjAXTu3XihQgEdnIg50c//zOC/MyLP0Clg+Y8Sh9ZjjnvBrDZU4DgXS9C3T9r4/scGZQ==" }, + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -4002,6 +4015,11 @@ } } }, + "classnames": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + }, "clean-css": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz", @@ -7819,9 +7837,9 @@ "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==" }, "immer": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz", - "integrity": "sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A==" + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz", + "integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==" }, "import-cwd": { "version": "2.1.0", @@ -10816,9 +10834,9 @@ } }, "open": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.0.tgz", - "integrity": "sha512-PGoBCX/lclIWlpS/R2PQuIR4NJoXh6X5AwVzE7WXnWRGvHg7+4TBCgsujUgiPpm0K1y4qvQeWnCWVTpTKZBtvA==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "requires": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -12985,9 +13003,9 @@ } }, "react-dev-utils": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.1.tgz", - "integrity": "sha512-rlgpCupaW6qQqvu0hvv2FDv40QG427fjghV56XyPcP5aKtOAPzNAhQ7bHqk1YdS2vpW1W7aSV3JobedxuPlBAA==", + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", + "integrity": "sha512-dx0LvIGHcOPtKbeiSUM4jqpBl3TcY7CDjZdfOIcKeznE7BWr9dg0iPG90G5yfVQ+p/rGNMXdbfStvzQZEVEi4A==", "requires": { "@babel/code-frame": "7.10.4", "address": "1.1.2", @@ -13002,13 +13020,13 @@ "global-modules": "2.0.0", "globby": "11.0.1", "gzip-size": "5.1.1", - "immer": "7.0.9", + "immer": "8.0.1", "is-root": "2.1.0", "loader-utils": "2.0.0", "open": "^7.0.2", "pkg-up": "3.1.0", "prompts": "2.4.0", - "react-error-overlay": "^6.0.8", + "react-error-overlay": "^6.0.9", "recursive-readdir": "2.2.2", "shell-quote": "1.7.2", "strip-ansi": "6.0.0", @@ -13126,9 +13144,9 @@ } }, "react-error-overlay": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.8.tgz", - "integrity": "sha512-HvPuUQnLp5H7TouGq3kzBeioJmXms1wHy9EGjz2OURWBp4qZO6AfGEcnxts1D/CbwPLRAgTMPCEgYhA3sEM4vw==" + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz", + "integrity": "sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==" }, "react-fast-compare": { "version": "3.2.0", @@ -13256,6 +13274,14 @@ "workbox-webpack-plugin": "5.1.4" } }, + "react-toastify": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-7.0.4.tgz", + "integrity": "sha512-Rol7+Cn39hZp5hQ/k6CbMNE2CKYV9E5OQdC/hBLtIQU2xz7DdAm7xil4NITQTHR6zEbE5RVFbpgSwTD7xRGLeQ==", + "requires": { + "clsx": "^1.1.1" + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index c160f8b..0fb9855 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", + "@types/classnames": "^2.2.11", + "axios": "^0.21.1", "brace": "^0.11.1", + "classnames": "^2.2.6", "jsoneditor": "^9.1.9", "jsoneditor-react": "^3.1.0", "node-sass": "^4.14.1", @@ -15,6 +18,7 @@ "react-icons": "^4.2.0", "react-router-dom": "^5.2.0", "react-scripts": "4.0.1", + "react-toastify": "^7.0.4", "semantic-ui-css": "^2.4.1", "semantic-ui-react": "^2.0.3", "typescript": "^4.1.3", diff --git a/src/App.tsx b/src/App.tsx index a661741..47da363 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,27 @@ import React from 'react'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; import './App.scss'; import BaseLayout from './components/layouts/BaseLayout'; import Ipfs from './pages/ipfs/Ipfs'; import IpfsList from './pages/ipfs/IpfsList'; +import TrackedContractsList from './pages/tracked-contracts/TrackedContractsList'; const App = (): JSX.Element => { return ( - - - - - - - - + <> + + + + + + + + + + + ); }; diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 0000000..d37af94 --- /dev/null +++ b/src/api/api.ts @@ -0,0 +1,70 @@ +import axios from 'axios'; + +// const OBTAIN_TOKEN = '/token/obtain/'; +// const REFRESH_TOKEN_URL = '/token/refresh/'; + +export const ACCESS_TOKEN = 'access_token'; +export const REFRESH_TOKEN = 'refresh_token'; + +// interface JWTTokenResponse { +// refresh: string; +// access: string; +// } + +export const axiosInstance = axios.create({ + baseURL: 'http://127.0.0.1:7424/api/', + timeout: 5000, + headers: { + // 'Authorization': localStorage.getItem('access_token') ? 'JWT ' + localStorage.getItem('access_token') : null, + 'Content-Type': 'application/json', + accept: 'application/json', + }, +}); + +// export const saveTokens = (jwtToken: JWTTokenResponse): JWTTokenResponse => { +// localStorage.setItem(ACCESS_TOKEN, jwtToken.access); +// localStorage.setItem(REFRESH_TOKEN, jwtToken.refresh); +// return jwtToken; +// }; + +const localGet = (key: string, defaultValue = ''): string => { + const value = localStorage.getItem(key); + if (value === null) return defaultValue; + return value; +}; + +export const getAccessToken = (): string => localGet(ACCESS_TOKEN); +export const getRefreshToken = (): string => localGet(REFRESH_TOKEN); + +axiosInstance.interceptors.response.use( + (res) => res, + (err) => { + // const originalRequest = err.config; + // If refresh tokens is expired redirect to login page + // if (err.response.status === 401 && originalRequest.url === REFRESH_TOKEN_URL) { + // window.location.href = '/login/'; + // return Promise.reject(err); + // } + + // // If access token is expired update it + // if (err.response.status === 401 && err.response.statusText === 'Unauthorized') { + // return axiosInstance + // .post(REFRESH_TOKEN_URL, {refresh: localStorage.getItem(REFRESH_TOKEN)}) + // .then(res => res.data) + // .then(saveTokens) + // .then(res => { + // axiosInstance.defaults.headers['Authorization'] = 'JWT ' + res.access; + // originalRequest.headers['Authorization'] = 'JWT ' + res.access; + + // return axiosInstance(originalRequest); + // }) + // } + + return Promise.reject(err); + }, +); + +// export const obtainTokenApi = async (email: string, password: string): Promise => +// axiosInstance +// .post(OBTAIN_TOKEN, { email, password }) +// .then((res) => res.data); diff --git a/src/api/chains.ts b/src/api/chains.ts new file mode 100644 index 0000000..fa4730e --- /dev/null +++ b/src/api/chains.ts @@ -0,0 +1,14 @@ +import { Chain } from '../interfaces'; +import { axiosInstance } from './api'; + +// Let it be implicit here to avoid importing AxiosResponse etc. + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +const chainsApi = '/chains'; + +const getAllChains = () => axiosInstance.get(`${chainsApi}`); +const getChain = (chain: string) => axiosInstance.get(`${chainsApi}/${chain}`); +const setChainStatus = (chain: string) => axiosInstance.put(`${chainsApi}/${chain}`); + +export default { getAllChains, getChain, setChainStatus }; diff --git a/src/api/contracts.ts b/src/api/contracts.ts new file mode 100644 index 0000000..d444539 --- /dev/null +++ b/src/api/contracts.ts @@ -0,0 +1,18 @@ +import { ApiResponse, Contract } from '../interfaces'; +import { axiosInstance } from './api'; + +const contractsApi = '/contracts'; + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +// Implementation : https://github.com/paralink-network/paralink-node/blob/master/src/api/contracts.py +const getAllContracts = () => axiosInstance.get(`${contractsApi}`); +const getChainContracts = (chain: string) => axiosInstance.get(`${contractsApi}/${chain}`); +const createContract = (contract: Omit) => axiosInstance.post(`${contractsApi}`, contract); +const setContractStatus = (chain: string, address: string, contract: Pick) => + axiosInstance.put(`${contractsApi}/${chain}/${address}`, contract); +const deleteContract = (chain: string, address: string) => + axiosInstance.delete(`${contractsApi}/${chain}/${address}`); + +export default { getAllContracts, getChainContracts, createContract, setContractStatus, deleteContract }; diff --git a/src/components/common/Buttons/Buttons.tsx b/src/components/common/Buttons/Buttons.tsx new file mode 100644 index 0000000..5a931f4 --- /dev/null +++ b/src/components/common/Buttons/Buttons.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +const BLUE_COLOR = 'blue'; +const GREEN_COLOR = 'green'; +const GRAY_COLOR = 'gray'; +const RED_COLOR = 'red'; + +type Color = typeof BLUE_COLOR | typeof GREEN_COLOR | typeof GRAY_COLOR | typeof RED_COLOR; + +interface Button { + onClick?: () => void; + color?: Color; + className?: string; + disabled?: boolean; +} + +const Button: React.FC +); + +export default Button; diff --git a/src/components/common/Buttons/index.ts b/src/components/common/Buttons/index.ts new file mode 100644 index 0000000..2da0511 --- /dev/null +++ b/src/components/common/Buttons/index.ts @@ -0,0 +1,3 @@ +import Button from './Buttons'; + +export default Button; diff --git a/src/components/common/Card/Card.tsx b/src/components/common/Card/Card.tsx new file mode 100644 index 0000000..0e01879 --- /dev/null +++ b/src/components/common/Card/Card.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const Card: React.FC<{ className?: string }> = ({ className = '', children }) => { + return
{children}
; +}; + +export default Card; diff --git a/src/components/common/Card/CardBody.tsx b/src/components/common/Card/CardBody.tsx new file mode 100644 index 0000000..5b4bc13 --- /dev/null +++ b/src/components/common/Card/CardBody.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const CardBody: React.FC<{}> = ({ children }) => { + return
{children}
; +}; + +export default CardBody; diff --git a/src/components/common/Card/CardFooter.tsx b/src/components/common/Card/CardFooter.tsx new file mode 100644 index 0000000..921085a --- /dev/null +++ b/src/components/common/Card/CardFooter.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const CardFooter: React.FC<{}> = ({ children }) => { + return
{children}
; +}; + +export default CardFooter; diff --git a/src/components/common/Card/CardHeader.tsx b/src/components/common/Card/CardHeader.tsx new file mode 100644 index 0000000..f456e6e --- /dev/null +++ b/src/components/common/Card/CardHeader.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const CardHeader: React.FC<{}> = ({ children }) => { + return
{children}
; +}; + +export default CardHeader; diff --git a/src/components/common/Card/index.ts b/src/components/common/Card/index.ts new file mode 100644 index 0000000..95e3ff7 --- /dev/null +++ b/src/components/common/Card/index.ts @@ -0,0 +1,4 @@ +export { default as Card } from './Card'; +export { default as CardBody } from './CardBody'; +export { default as CardFooter } from './CardFooter'; +export { default as CardHeader } from './CardHeader'; diff --git a/src/components/common/Input/SearchInput.tsx b/src/components/common/Input/SearchInput.tsx new file mode 100644 index 0000000..db8e72c --- /dev/null +++ b/src/components/common/Input/SearchInput.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { FaSearch, FaTimes } from 'react-icons/fa'; + +interface SearchInput { + value: any; + onChange: (value: string) => void; + name?: string; + id?: string; + className?: string; + placeholder?: string; +} + +const SearchInput: React.FC = ({ + value, + onChange, + name = 'search', + id = 'search', + className = '', + placeholder = '', +}): JSX.Element => { + return ( +
+
+ {/** TODO: Remove this and actually use the other icons library once we get the query manager merged in */} + {value ? ( + + ) : ( + + )} +
+ onChange(event.target.value)} + /> +
+ ); +}; + +export default SearchInput; diff --git a/src/components/common/Input/index.ts b/src/components/common/Input/index.ts new file mode 100644 index 0000000..8300e87 --- /dev/null +++ b/src/components/common/Input/index.ts @@ -0,0 +1,3 @@ +import SearchInput from './SearchInput'; + +export default SearchInput; diff --git a/src/components/common/Table/Table.tsx b/src/components/common/Table/Table.tsx new file mode 100644 index 0000000..0c5c99a --- /dev/null +++ b/src/components/common/Table/Table.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const Table: React.FC<{ className?: string }> = ({ className = '', children }): JSX.Element => { + return {children}
; +}; + +export default Table; diff --git a/src/components/common/Table/TableBody.tsx b/src/components/common/Table/TableBody.tsx new file mode 100644 index 0000000..fbe97b3 --- /dev/null +++ b/src/components/common/Table/TableBody.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const TableBody: React.FC<{ className?: string }> = ({ className = '', children }): JSX.Element => { + return {children}; +}; + +export default TableBody; diff --git a/src/components/common/Table/TableCell.tsx b/src/components/common/Table/TableCell.tsx new file mode 100644 index 0000000..d6760d7 --- /dev/null +++ b/src/components/common/Table/TableCell.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const TableCell: React.FC<{ className?: string }> = ({ className = '', children }): JSX.Element => { + return {children}; +}; + +export default TableCell; diff --git a/src/components/common/Table/TableHeader.tsx b/src/components/common/Table/TableHeader.tsx new file mode 100644 index 0000000..93ff9d8 --- /dev/null +++ b/src/components/common/Table/TableHeader.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const TableHeader: React.FC<{ className?: string }> = ({ className = '', children }): JSX.Element => { + return {children}; +}; + +export default TableHeader; diff --git a/src/components/common/Table/TableHeaderCell.tsx b/src/components/common/Table/TableHeaderCell.tsx new file mode 100644 index 0000000..a920c29 --- /dev/null +++ b/src/components/common/Table/TableHeaderCell.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +const TableHeaderCell: React.FC<{ className?: string }> = ({ className = '', children }): JSX.Element => { + return ( + + {children} + + ); +}; + +export default TableHeaderCell; diff --git a/src/components/common/Table/TableRow.tsx b/src/components/common/Table/TableRow.tsx new file mode 100644 index 0000000..a641fd8 --- /dev/null +++ b/src/components/common/Table/TableRow.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const TableRow: React.FC<{ className?: string }> = ({ className = '', children }): JSX.Element => { + return {children}; +}; + +export default TableRow; diff --git a/src/components/common/Table/index.ts b/src/components/common/Table/index.ts new file mode 100644 index 0000000..5507ba5 --- /dev/null +++ b/src/components/common/Table/index.ts @@ -0,0 +1,6 @@ +export { default as Table } from './Table'; +export { default as TableBody } from './TableBody'; +export { default as TableCell } from './TableCell'; +export { default as TableHeader } from './TableHeader'; +export { default as TableHeaderCell } from './TableHeaderCell'; +export { default as TableRow } from './TableRow'; diff --git a/src/components/common/Toggle/Toggle.tsx b/src/components/common/Toggle/Toggle.tsx new file mode 100644 index 0000000..c2c1a7a --- /dev/null +++ b/src/components/common/Toggle/Toggle.tsx @@ -0,0 +1,42 @@ +import classnames from 'classnames'; +import React from 'react'; + +export interface ToggleProps { + color?: string; + enabled: boolean; + onClick: () => void; +} + +const Toggle: React.FC = ({ enabled, onClick, color }) => { + return ( + + ); +}; + +export default Toggle; diff --git a/src/components/common/Toggle/index.ts b/src/components/common/Toggle/index.ts new file mode 100644 index 0000000..2948603 --- /dev/null +++ b/src/components/common/Toggle/index.ts @@ -0,0 +1,3 @@ +import Toggle from './Toggle'; + +export default Toggle; diff --git a/src/components/common/index.ts b/src/components/common/index.ts new file mode 100644 index 0000000..21643e1 --- /dev/null +++ b/src/components/common/index.ts @@ -0,0 +1,2 @@ +export * from './Card'; +export * from './Table'; diff --git a/src/components/layouts/NavigationBar/NavigationBar.tsx b/src/components/layouts/NavigationBar/NavigationBar.tsx index 68fee8e..e1aee0e 100644 --- a/src/components/layouts/NavigationBar/NavigationBar.tsx +++ b/src/components/layouts/NavigationBar/NavigationBar.tsx @@ -19,6 +19,9 @@ const NavigationBar = (): JSX.Element => {
  • IPFS
  • +
  • + Tracked Contracts +
  • diff --git a/src/interfaces/ApiResponse.ts b/src/interfaces/ApiResponse.ts new file mode 100644 index 0000000..75604f1 --- /dev/null +++ b/src/interfaces/ApiResponse.ts @@ -0,0 +1,4 @@ +export interface ApiResponse { + result: 'ok' | 'error'; + error?: string; +} diff --git a/src/interfaces/Chain.ts b/src/interfaces/Chain.ts new file mode 100644 index 0000000..6ee3b40 --- /dev/null +++ b/src/interfaces/Chain.ts @@ -0,0 +1,8 @@ +import { Contract } from './Contract'; + +export interface Chain { + name: string; + type: string; + active: boolean; + contracts: Contract[]; +} diff --git a/src/interfaces/Contract.ts b/src/interfaces/Contract.ts new file mode 100644 index 0000000..3ed929c --- /dev/null +++ b/src/interfaces/Contract.ts @@ -0,0 +1,6 @@ +export interface Contract { + active: boolean; + address: string; + chain: string; + newContract?: boolean; +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts new file mode 100644 index 0000000..94f6e4f --- /dev/null +++ b/src/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './ApiResponse'; +export * from './Chain'; +export * from './Contract'; diff --git a/src/pages/ipfs/Ipfs.tsx b/src/pages/ipfs/Ipfs.tsx index 39e2d2f..a7f5d1b 100644 --- a/src/pages/ipfs/Ipfs.tsx +++ b/src/pages/ipfs/Ipfs.tsx @@ -1,16 +1,14 @@ -import React, { ReactNode } from 'react'; -import { Link } from 'react-router-dom'; -import { Button, Grid, Header, Message, Segment } from 'semantic-ui-react'; - import ace from 'brace'; import 'brace/mode/json'; import 'brace/theme/github'; - // No typings for jsoneditor-react for now just using @ts-ignore for getting around the issue // Later on with advanced design we might not used that library. If we do we can as well define typings for it // @ts-ignore import { JsonEditor as Editor } from 'jsoneditor-react'; import 'jsoneditor-react/es/editor.min.css'; +import React, { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { Button, Grid, Header, Message, Segment } from 'semantic-ui-react'; interface IpfsProps { match: { params: { hash: string } }; diff --git a/src/pages/tracked-contracts/TrackedChain.tsx b/src/pages/tracked-contracts/TrackedChain.tsx new file mode 100644 index 0000000..1500d58 --- /dev/null +++ b/src/pages/tracked-contracts/TrackedChain.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +interface TrackedChainProps { + name: string; + active: boolean; + onClick: () => void; +} + +const TrackedChain: React.FC = ({ name, active, onClick }) => { + return ( +
    onClick()} + onKeyPress={() => onClick()} + tabIndex={0} + > + {name} +
    + ); +}; + +export default TrackedChain; diff --git a/src/pages/tracked-contracts/TrackedContract.tsx b/src/pages/tracked-contracts/TrackedContract.tsx new file mode 100644 index 0000000..82ad407 --- /dev/null +++ b/src/pages/tracked-contracts/TrackedContract.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { FaCheck, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa'; +import { toast } from 'react-toastify'; +import ContractsApi from '../../api/contracts'; +import Button from '../../components/common/Buttons'; +import Toggle from '../../components/common/Toggle'; +import { Contract } from '../../interfaces'; +import ContractUtil from '../../utils/ContractUtil'; + +interface TrackedContractProps { + trackedContract: Contract; + // saved: (contract: TrackedContract) => void; // TODO: Once we have the BE working use this function to do a callback to the parent to update with the id or the new status + remove: () => void; // This helps remove the line from the array and refresh the parent view + update: (contract: Contract) => void; + focused?: boolean; +} + +const TrackedContractRow: React.FC = ({ trackedContract, remove, update, focused }) => { + const [inputFocused, setInputFocused] = useState(false); + const [edited, setEdited] = useState(false); + const [contract, setContract] = useState(trackedContract); + const [loadingUpdate, setLoadingUpdate] = useState(false); + // Keep the original contract. + const initialContract = trackedContract; + + // Reference to the input + const trackInputRef = useRef(null); + + useEffect(() => { + // When adding a new line we want straight awayt to focus the new line + if (focused && trackInputRef.current) { + trackInputRef.current.focus(); + } + }, [focused, trackInputRef]); + + const showNotification = (title: string, type: 'success' | 'error'): void => { + let displayNotification: any = toast; + let autoClose = 2000; + if (type === 'success') { + displayNotification = toast.success; + } else if (type === 'error') { + displayNotification = toast.error; + autoClose = 8000; + } + displayNotification(title, { + autoClose, + }); + }; + + // Handle the UI state for the contrat changing + const handleContractChange = (value: Partial): void => { + setContract({ ...contract, ...value }); + setEdited(true); + }; + + // reset the state to lose focus on the row + const loseFocus = (): void => { + setEdited(false); + setInputFocused(false); + + if (trackInputRef.current) { + trackInputRef.current.blur(); + } + }; + + // Save the contract + const saveClick = (): void => { + setLoadingUpdate(true); + // save contract as it is + // then set it as not edited anymore + if (contract.newContract) { + // TODO: Should have the BE send us the response of the new object so we can update the view ( example with the id ) + const payload = { + address: contract.address, + chain: contract.chain, + active: contract.active, + }; + + // TODO: add probably a response interceptor ( if there is such thing in react ) for all axios request so we can avoid "any in responses" + // and actually only have the body + ContractsApi.createContract(payload) + .then((response: any) => { + if (response.data?.result === 'ok') { + contract.newContract = false; + loseFocus(); + update(contract); + showNotification(`Contract ${ContractUtil.displayContract(contract.address)} has been added`, 'success'); + } else if (response.data?.error) { + console.error('error', response.data?.error); + showNotification(response.data?.error, 'error'); + } + }) + .catch((e: any) => { + console.error('error', e); + showNotification(`Error when adding ${ContractUtil.displayContract(contract.address)}`, 'error'); + }) + .finally(() => setLoadingUpdate(false)); + } else { + // TODO: same as above + ContractsApi.setContractStatus(contract.chain, contract.address, { active: contract.active }) + .then((response: any) => { + if (response.data?.result === 'ok') { + loseFocus(); + showNotification(`Contract ${ContractUtil.displayContract(contract.address)} updated`, 'success'); + } else if (response.data?.error) { + console.error('error', response.data?.error); + showNotification(response.data?.error, 'error'); + } + }) + .catch((e: any) => { + console.error('error', e); + showNotification(`Error when trying to update ${ContractUtil.displayContract(contract.address)}`, 'error'); + }) + .finally(() => setLoadingUpdate(false)); + } + }; + + const deleteClick = (): void => { + if (!contract.newContract) { + // TODO: Handle the errors and exception + // This should be done when we have our notification component + // basically we'd make the notification component appear with the error + // Potentially add an outline to the line as red until it's touched ( Optional ) + setLoadingUpdate(true); + ContractsApi.deleteContract(contract.chain, contract.address) + .then((response: any) => { + if (response.data?.result === 'ok') { + remove(); + showNotification(`Contract ${ContractUtil.displayContract(contract.address)} deleted`, 'success'); + } else if (response.data?.error) { + console.error('error', response.data?.error); + showNotification(response.data?.error, 'error'); + } + }) + .catch((e: any) => { + console.error('delete error', e); + showNotification( + `Error when trying to delete Contract ${ContractUtil.displayContract(contract.address)}`, + 'error', + ); + }) + .finally(() => { + setLoadingUpdate(false); + }); + } else { + remove(); + } + }; + + // Cancel the changes on the contract and revert back to original + const cancelClick = (): void => { + setContract(initialContract); + loseFocus(); + }; + + // Templates + const saveCancelButtons = ( + <> + + + + ); + + const loading = ( + <> + + + + + ); + + const removeButton = ( + + ); + + return ( +
    +
    + {!contract.newContract ? ( + {contract.address} + ) : ( + setInputFocused(true)} + onBlur={() => setInputFocused(false)} + onChange={(event) => handleContractChange({ address: event.target.value })} + /> + )} +
    +
    + {!loadingUpdate && (inputFocused || edited) ?
    {saveCancelButtons}
    : ''} + {loadingUpdate ?
    {loading}
    : ''} + + { + handleContractChange({ active: !contract.active }); + }} + /> + {removeButton} +
    +
    + ); +}; + +export default TrackedContractRow; diff --git a/src/pages/tracked-contracts/TrackedContractsList.tsx b/src/pages/tracked-contracts/TrackedContractsList.tsx new file mode 100644 index 0000000..c28a10a --- /dev/null +++ b/src/pages/tracked-contracts/TrackedContractsList.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from 'react'; +import { FaPlus } from 'react-icons/fa'; +import ChainsApi from '../../api/chains'; +import { Card, CardBody, CardHeader } from '../../components/common'; +import Button from '../../components/common/Buttons'; +import SearchInput from '../../components/common/Input'; +import { Chain, Contract } from '../../interfaces'; +import TrackedChain from './TrackedChain'; +import TrackedContractRow from './TrackedContract'; + +const TrackedContractsList: React.FC<{}> = () => { + const [chains, setChains] = useState([]); + const [activeChain, setActiveChain] = useState(); + const [searchChain, setSearchChain] = useState(''); + const [focused, setFocused] = useState(false); + + // TODO: current view, we should block user from switching subtrate if they did a change currently + // have a call from the tracked contract when something changed to let the parent know + // We should have a modal displaying that there is some current changes to update + // this is just a safe precaution + useEffect(() => { + // TODO: Add potential error or nice fallback here + ChainsApi.getAllChains().then((res: any) => { + setChains(res.data.chains); + // Select the first chain as active + setActiveChain(res.data.chains[0]); + }); + }, []); + + // Filter out by the search but also keep the current active in the search + const allChains = chains + .filter((chain) => chain.name.includes(searchChain.trim().toLocaleLowerCase()) || activeChain?.name === chain.name) + .map((chain) => ( + setActiveChain(chain)} + /> + )); + + const addContract = (): void => { + // Check if there is already an empty entry in the array to fill + let emptyContractExisting = false; + if (activeChain?.contracts.length) { + const lastEntry = activeChain?.contracts[activeChain?.contracts.length - 1]; + emptyContractExisting = lastEntry.address === ''; + } + + const entry = { address: '', active: false, chain: activeChain?.name || '', newContract: true }; + if (activeChain && !emptyContractExisting) { + setActiveChain({ + ...activeChain, + contracts: [...activeChain.contracts, entry], + }); + setFocused(true); + } + }; + + // Update the value in the list + const updateContract = (contract: Contract, index: number): void => { + // const entry = { address: '', active: false, chain: activeChain?.name || '', newContract: true }; + const newContracts = activeChain?.contracts.slice(0); + if (newContracts) { + newContracts[index] = contract; + if (activeChain) { + setActiveChain({ + ...activeChain, + contracts: newContracts, + }); + } + } + }; + + // Remove a contract from the chain visually + const removeContract = (index: number): void => { + if (activeChain?.contracts.length) { + activeChain.contracts.splice(index, 1); + setActiveChain({ + ...activeChain, + }); + } + }; + + return ( +
    +
    +
    + +
    + setSearchChain(val)} + placeholder="Search" + className="py-3 text-lg leading-relaxed max-w-full" + /> +
    + +
    {allChains}
    +
    +
    +
    +
    + + + +
    +
    Tracked Contracts
    +
    + +
    +
    +
    +
    + {activeChain?.contracts.map((contract: Contract, index: number) => { + return ( + removeContract(index)} + update={(c: Contract) => updateContract(c, index)} + /> + ); + })} +
    +
    +
    +
    +
    +
    + ); +}; + +export default TrackedContractsList; diff --git a/src/pages/tracked-contracts/index.ts b/src/pages/tracked-contracts/index.ts new file mode 100644 index 0000000..36fe015 --- /dev/null +++ b/src/pages/tracked-contracts/index.ts @@ -0,0 +1 @@ +export { default as TrackedContractsList } from './TrackedContractsList'; diff --git a/src/pages/tracked-contracts/mock-data.ts b/src/pages/tracked-contracts/mock-data.ts new file mode 100644 index 0000000..5dd0fa9 --- /dev/null +++ b/src/pages/tracked-contracts/mock-data.ts @@ -0,0 +1,34 @@ +// We can remove this when we can hook to the BE and get that data it's just for getting the UX ready for it +// TODO: Some of those aren't set up in the BE +const mockDataChain = [ + { + name: 'eth.mainnet', + type: 'evm', + project: 'eth', + url: 'https://mainnet.infura.io/v3/', + credentials: {}, + tracked_contracts: [], + }, + { + name: 'eth.dev-mainnet-fork', + type: 'evm', + project: 'eth', + url: 'ws://localhost:8545', + credentials: { + private_key: '', + }, + tracked_contracts: [{ address: '0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87', enabled: true }], + }, + { + name: 'dev-canvas', + type: 'substrate', + project: 'canvas', + url: 'ws://127.0.0.1:9944', + credentials: { + private_key: '', + public_key: '', + }, + tracked_contracts: [{ address: '5FnDzvXcnu3RCtQ3f3RFqaQnVLfcWLZ9FvUURNoPMdmsKZXP', enabled: false }], + }, +]; +export default mockDataChain; diff --git a/src/utils/ContractUtil.ts b/src/utils/ContractUtil.ts new file mode 100644 index 0000000..1741423 --- /dev/null +++ b/src/utils/ContractUtil.ts @@ -0,0 +1,14 @@ +export default class ContractUtil { + /** + * Display a contract address friendly with dots + * Input: 0x3e53a972d62b19ad9e308bb0e4a4b1b78f163ccf + * Returns: 0x3e53...3ccf + */ + static displayContract(contractAddress: string): string { + // Safe check + if (contractAddress.length > 10) { + return `${contractAddress.substring(0, 6)}...${contractAddress.slice(-4)}`; + } + return contractAddress; + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..4346bbc --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export { default as ContractUtil } from './ContractUtil'; diff --git a/tailwind.config.js b/tailwind.config.js index 296c1b9..340cc67 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,29 +1,33 @@ module.exports = { theme: { - colors: { - primary: { - DEFAULT: 'var(--color-primary)', - light: 'var(--color-primary-light)', + extend: { + colors: { + primary: { + DEFAULT: 'var(--color-primary)', + light: 'var(--color-primary-light)', + }, + secondary: { + DEFAULT: 'var(--color-secondary)', + light: 'var(--color-secondary-light)', + lightest: 'var(--color-secondary-lightest)', + darkest: 'var(--color-secondary-darkest)', + }, + dark: 'var(--color-dark)', + light: 'var(--color-light)', }, - secondary: { - DEFAULT: 'var(--color-secondary)', - light: 'var(--color-secondary-light)', - lightest: 'var(--color-secondary-lightest)', - darkest: 'var(--color-secondary-darkest)', + fontFamily: { + sans: ['Poppins'], + serif: ['Georgia'], + mono: ['Roboto'], }, - dark: 'var(--color-dark)', - light: 'var(--color-light)', - }, - fontFamily: { - sans: ['Poppins'], - serif: ['Georgia'], - mono: ['Roboto'], }, }, purge: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'], darkMode: false, // or 'media' or 'class' variants: { - extend: {}, + extend: { + opacity: ['disabled'], + }, }, plugins: [], };