diff --git a/CHANGELOG.md b/CHANGELOG.md index 11655e7ea..2c3774107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features +- [#616](https://github.com/alleslabs/celatone-frontend/pull/616) Add table for saved accounts and add save, remove and edit modal - [#613](https://github.com/alleslabs/celatone-frontend/pull/613) Add saved accounts modal ui - [#611](https://github.com/alleslabs/celatone-frontend/pull/611) Add saved accounts page - [#608](https://github.com/alleslabs/celatone-frontend/pull/608) Deprecate stone 10 diff --git a/src/lib/app-provider/contexts/app.tsx b/src/lib/app-provider/contexts/app.tsx index cc66c6315..8d1f25fcd 100644 --- a/src/lib/app-provider/contexts/app.tsx +++ b/src/lib/app-provider/contexts/app.tsx @@ -24,6 +24,7 @@ import { useCodeStore, useContractStore, usePublicProjectStore, + useAccountStore, } from "lib/providers/store"; import { formatUserKey } from "lib/utils"; @@ -51,6 +52,7 @@ export const AppProvider = observer(({ children }: AppProviderProps) => { const { setCodeUserKey, isCodeUserKeyExist } = useCodeStore(); const { setContractUserKey, isContractUserKeyExist } = useContractStore(); const { setProjectUserKey, isProjectUserKeyExist } = usePublicProjectStore(); + const { setAccountUserKey, isAccountUserKeyExist } = useAccountStore(); const { setModalTheme } = useModalTheme(); const [currentChainName, setCurrentChainName] = useState(); @@ -85,8 +87,15 @@ export const AppProvider = observer(({ children }: AppProviderProps) => { setCodeUserKey(userKey); setContractUserKey(userKey); setProjectUserKey(userKey); + setAccountUserKey(userKey); } - }, [currentChainName, setCodeUserKey, setContractUserKey, setProjectUserKey]); + }, [ + currentChainName, + setCodeUserKey, + setContractUserKey, + setProjectUserKey, + setAccountUserKey, + ]); // Disable "Leave page" alert useEffect(() => { @@ -113,6 +122,7 @@ export const AppProvider = observer(({ children }: AppProviderProps) => { !isCodeUserKeyExist() || !isContractUserKeyExist() || !isProjectUserKeyExist() || + !isAccountUserKeyExist() || !currentChainId ) return ; diff --git a/src/lib/components/json-schema/EditSchemaButtons.tsx b/src/lib/components/json-schema/EditSchemaButtons.tsx index 1a3fca5e6..fe7f70f47 100644 --- a/src/lib/components/json-schema/EditSchemaButtons.tsx +++ b/src/lib/components/json-schema/EditSchemaButtons.tsx @@ -40,9 +40,8 @@ export const EditSchemaButtons = ({ trigger={ } aria-label="delete schema" /> diff --git a/src/lib/components/modal/ActionModal.tsx b/src/lib/components/modal/ActionModal.tsx index 088d655e6..6e1b23b9a 100644 --- a/src/lib/components/modal/ActionModal.tsx +++ b/src/lib/components/modal/ActionModal.tsx @@ -56,7 +56,7 @@ export function ActionModal({ otherVariant = "outline-primary", noCloseButton = false, closeOnOverlayClick = true, - buttonRemark = "Information will be stored locally on your device.", + buttonRemark, }: ActionModalProps) { const { isOpen, onOpen, onClose } = useDisclosure(); @@ -92,7 +92,11 @@ export function ActionModal({ - + {title} @@ -131,9 +135,11 @@ export function ActionModal({ {otherBtnTitle} - - {buttonRemark} - + {buttonRemark && ( + + {buttonRemark} + + )} diff --git a/src/lib/components/modal/account/EditSavedAccount.tsx b/src/lib/components/modal/account/EditSavedAccount.tsx new file mode 100644 index 000000000..ea84a3d52 --- /dev/null +++ b/src/lib/components/modal/account/EditSavedAccount.tsx @@ -0,0 +1,117 @@ +import { Flex, Text } from "@chakra-ui/react"; +import { useCallback, useEffect, useMemo } from "react"; +import { useForm } from "react-hook-form"; + +import { ActionModal } from "../ActionModal"; +import { useCelatoneApp } from "lib/app-provider"; +import { ExplorerLink } from "lib/components/ExplorerLink"; +import { ControllerInput, ControllerTextarea } from "lib/components/forms"; +import { useGetMaxLengthError, useHandleAccountSave } from "lib/hooks"; +import type { AccountLocalInfo } from "lib/stores/account"; + +import type { SaveAccountDetail } from "./SaveNewAccount"; + +interface EditSavedAccountModalProps { + account: AccountLocalInfo; + triggerElement: JSX.Element; +} + +export const EditSavedAccountModal = ({ + account, + triggerElement, +}: EditSavedAccountModalProps) => { + const { constants } = useCelatoneApp(); + const getMaxLengthError = useGetMaxLengthError(); + const defaultValues = useMemo(() => { + return { + address: account.address, + name: account.name ?? "", + description: account.description ?? "", + }; + }, [account]); + + const { + control, + watch, + reset, + formState: { errors }, + } = useForm({ + defaultValues, + mode: "all", + }); + + const addressState = watch("address"); + const nameState = watch("name"); + const descriptionState = watch("description"); + + const resetForm = useCallback( + () => reset(defaultValues), + [defaultValues, reset] + ); + + useEffect(() => { + resetForm(); + }, [resetForm]); + + const handleSave = useHandleAccountSave({ + title: `Updated Saved Account!`, + address: addressState, + name: nameState, + description: descriptionState, + actions: () => {}, + }); + + return ( + + + Account Address + + + + } + trigger={triggerElement} + mainBtnTitle="Save" + mainAction={handleSave} + disabledMain={!!errors.name || !!errors.description} + otherBtnTitle="Cancel" + otherAction={resetForm} + closeOnOverlayClick={false} + > + + + + + + ); +}; diff --git a/src/lib/components/modal/account/RemoveSavedAccount.tsx b/src/lib/components/modal/account/RemoveSavedAccount.tsx new file mode 100644 index 000000000..1e16443c6 --- /dev/null +++ b/src/lib/components/modal/account/RemoveSavedAccount.tsx @@ -0,0 +1,84 @@ +import { + useToast, + Text, + chakra, + IconButton, + Highlight, +} from "@chakra-ui/react"; +import { useCallback } from "react"; + +import { ActionModal } from "../ActionModal"; +import { CustomIcon } from "lib/components/icon"; +import { useAccountStore } from "lib/providers/store"; +import type { AccountLocalInfo } from "lib/stores/account"; +import { truncate } from "lib/utils"; + +const StyledIconButton = chakra(IconButton, { + baseStyle: { + display: "flex", + alignItems: "center", + fontSize: "22px", + borderRadius: "36px", + }, +}); + +interface RemoveSavedAccountModalProps { + account: AccountLocalInfo; + trigger?: JSX.Element; +} +export function RemoveSavedAccountModal({ + account, + trigger = ( + } + variant="ghost-gray" + /> + ), +}: RemoveSavedAccountModalProps) { + const toast = useToast(); + const { removeSavedAccount } = useAccountStore(); + const handleRemove = useCallback(() => { + removeSavedAccount(account.address); + + toast({ + title: `Removed \u2018${account.name}\u2019 from Saved Codes`, + status: "success", + duration: 5000, + isClosable: false, + position: "bottom-right", + icon: , + }); + }, [removeSavedAccount, account.address, account.name, toast]); + return ( + + + + {`This action will remove \u2018${ + account.name ?? truncate(account.address) + }\u2019 from Saved Accounts. + You can save this address again later, but you will need to add its new account name and description.`} + + + + ); +} diff --git a/src/lib/components/modal/account/SaveNewAccount.tsx b/src/lib/components/modal/account/SaveNewAccount.tsx index 13c975785..21ec4edac 100644 --- a/src/lib/components/modal/account/SaveNewAccount.tsx +++ b/src/lib/components/modal/account/SaveNewAccount.tsx @@ -15,7 +15,7 @@ import { useGetMaxLengthError, useHandleAccountSave } from "lib/hooks"; import { useAccountStore } from "lib/providers/store"; import type { Addr } from "lib/types"; -interface SaveNewAccountDetail { +export interface SaveAccountDetail { address: Addr; name: string; description: string; @@ -31,7 +31,7 @@ export function SaveNewAccountModal({ buttonProps }: SaveNewAccountModalProps) { const getMaxLengthError = useGetMaxLengthError(); const { isAccountSaved } = useAccountStore(); - const defaultValues: SaveNewAccountDetail = { + const defaultValues: SaveAccountDetail = { address: "" as Addr, name: "", description: "", @@ -42,7 +42,7 @@ export function SaveNewAccountModal({ buttonProps }: SaveNewAccountModalProps) { watch, reset, formState: { errors }, - } = useForm({ + } = useForm({ defaultValues, mode: "all", }); @@ -161,7 +161,7 @@ export function SaveNewAccountModal({ buttonProps }: SaveNewAccountModalProps) { }} error={ errors.description && - getMaxLengthError(descriptionState.length, "contract_desc") + getMaxLengthError(descriptionState.length, "account_desc") } /> diff --git a/src/lib/components/state/ZeroState.tsx b/src/lib/components/state/ZeroState.tsx index 16c5e6e74..5ae680efe 100644 --- a/src/lib/components/state/ZeroState.tsx +++ b/src/lib/components/state/ZeroState.tsx @@ -4,6 +4,7 @@ import { CustomIcon } from "../icon"; import { useInternalNavigate } from "lib/app-provider"; import { SaveNewContractModal } from "lib/components/modal/contract"; import { ADMIN_SPECIAL_SLUG, INSTANTIATED_LIST_NAME } from "lib/data"; +import { SaveAccountButton } from "lib/pages/saved-accounts/components/SaveAccountButton"; import type { LVPair } from "lib/types"; import { formatSlugName } from "lib/utils"; @@ -86,3 +87,26 @@ export const ZeroState = ({ list, isReadOnly }: ZeroStateProps) => { ); }; + +export const AccountZeroState = () => ( + + + You don’t have any saved accounts. + + Save an account and entering the account address through + + + + Saved accounts are stored locally on your device.. + + +); diff --git a/src/lib/components/table/accounts/SavedAccountsNameCell.tsx b/src/lib/components/table/accounts/SavedAccountsNameCell.tsx new file mode 100644 index 000000000..e5dc18422 --- /dev/null +++ b/src/lib/components/table/accounts/SavedAccountsNameCell.tsx @@ -0,0 +1,28 @@ +import { EditableCell } from "../EditableCell"; +import { useCelatoneApp } from "lib/app-provider"; +import { useHandleAccountSave } from "lib/hooks"; +import type { AccountLocalInfo } from "lib/stores/account"; + +interface SaveAccountsNameCellProps { + account: AccountLocalInfo; +} +export const SaveAccountsNameCell = ({ + account, +}: SaveAccountsNameCellProps) => { + const { constants } = useCelatoneApp(); + const onSave = useHandleAccountSave({ + title: "Changed name successfully!", + address: account.address, + name: account.name ?? "", + description: account.description, + actions: () => {}, + }); + return ( + + ); +}; diff --git a/src/lib/components/table/accounts/SavedAccountsTable.tsx b/src/lib/components/table/accounts/SavedAccountsTable.tsx new file mode 100644 index 000000000..4782df7f1 --- /dev/null +++ b/src/lib/components/table/accounts/SavedAccountsTable.tsx @@ -0,0 +1,37 @@ +import { TableContainer } from "@chakra-ui/react"; + +import { Loading } from "lib/components/Loading"; +import type { AccountLocalInfo } from "lib/stores/account"; +import type { Option } from "lib/types"; + +import { SavedAccountsTableHeader } from "./SavedAccountsTableHeader"; +import { SavedAccountsTableRow } from "./SavedAccountsTableRow"; + +interface SavedAccountsTableProps { + accounts: Option; + isLoading: boolean; + emptyState: JSX.Element; +} +export const SavedAccountsTable = ({ + accounts, + isLoading, + emptyState, +}: SavedAccountsTableProps) => { + if (isLoading) return ; + if (!accounts?.length) return emptyState; + + const templateColumns = + "max(160px) minmax(200px, 1fr) minmax(250px, 1fr) max(100px)"; + + return ( + + + {accounts.map((account) => ( + + ))} + + ); +}; diff --git a/src/lib/components/table/accounts/SavedAccountsTableHeader.tsx b/src/lib/components/table/accounts/SavedAccountsTableHeader.tsx new file mode 100644 index 000000000..ce6d2259c --- /dev/null +++ b/src/lib/components/table/accounts/SavedAccountsTableHeader.tsx @@ -0,0 +1,17 @@ +import type { GridProps } from "@chakra-ui/react"; +import { Grid } from "@chakra-ui/react"; + +import { TableHeader } from "../tableComponents"; + +export const SavedAccountsTableHeader = ({ + templateColumns, +}: { + templateColumns: GridProps["templateColumns"]; +}) => ( + + Account Address + Account Name + Account Description + + +); diff --git a/src/lib/components/table/accounts/SavedAccountsTableRow.tsx b/src/lib/components/table/accounts/SavedAccountsTableRow.tsx new file mode 100644 index 000000000..e779320aa --- /dev/null +++ b/src/lib/components/table/accounts/SavedAccountsTableRow.tsx @@ -0,0 +1,86 @@ +import { Grid, IconButton, Text } from "@chakra-ui/react"; + +import { TableRow } from "../tableComponents"; +import { useInternalNavigate } from "lib/app-provider"; +import { ExplorerLink } from "lib/components/ExplorerLink"; +import { CustomIcon } from "lib/components/icon"; +import { EditSavedAccountModal } from "lib/components/modal/account/EditSavedAccount"; +import { RemoveSavedAccountModal } from "lib/components/modal/account/RemoveSavedAccount"; +import type { AccountLocalInfo } from "lib/stores/account"; +import type { Addr } from "lib/types"; + +import { SaveAccountsNameCell } from "./SavedAccountsNameCell"; + +interface SavedAccountsTableRowProps { + accountInfo: AccountLocalInfo; + templateColumns: string; +} + +export const SavedAccountsTableRow = ({ + accountInfo, + templateColumns, +}: SavedAccountsTableRowProps) => { + const navigate = useInternalNavigate(); + + const onRowSelect = (address: Addr) => + navigate({ + pathname: "/accounts/[accountAddress]", + query: { accountAddress: address }, + }); + return ( + onRowSelect(accountInfo.address)} + _hover={{ bg: "gray.900" }} + transition="all 0.25s ease-in-out" + cursor="pointer" + minW="min-content" + > + + + + + + + + + {accountInfo.description ?? "No description"} + + + + } + aria-label="edit account" + /> + } + /> + } + aria-label="remove account" + /> + } + /> + + + ); +}; diff --git a/src/lib/pages/saved-accounts/components/SavedAccountsSection.tsx b/src/lib/pages/saved-accounts/components/SavedAccountsSection.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/pages/saved-accounts/index.tsx b/src/lib/pages/saved-accounts/index.tsx index 4ce030676..15b037372 100644 --- a/src/lib/pages/saved-accounts/index.tsx +++ b/src/lib/pages/saved-accounts/index.tsx @@ -1,12 +1,34 @@ import { Badge, Text, Flex, Heading } from "@chakra-ui/react"; import { observer } from "mobx-react-lite"; +import { useMemo, useState } from "react"; +import InputWithIcon from "lib/components/InputWithIcon"; import PageContainer from "lib/components/PageContainer"; -import { EmptyState } from "lib/components/state"; +import { AccountZeroState, EmptyState } from "lib/components/state"; +import { SavedAccountsTable } from "lib/components/table/accounts/SavedAccountsTable"; +import { useAccountStore } from "lib/providers/store"; import { SaveAccountButton } from "./components/SaveAccountButton"; const SavedAccounts = observer(() => { + const { getSavedAccounts, isHydrated } = useAccountStore(); + const savedAccounts = getSavedAccounts(); + const accountsCount = savedAccounts.length; + + const [keyword, setKeyword] = useState(""); + + const isSearching = !!keyword; + + const filteredsavedAccounts = useMemo(() => { + if (!keyword) return savedAccounts; + return savedAccounts.filter( + (account) => + account.address.includes(keyword.toLowerCase()) || + account.name?.toLowerCase().includes(keyword.toLowerCase()) || + account.description?.toLowerCase().includes(keyword.toLowerCase()) + ); + }, [keyword, savedAccounts]); + return ( @@ -22,25 +44,34 @@ const SavedAccounts = observer(() => { Saved Accounts - 0 + {accountsCount} Your saved accounts will be stored locally - {/* ) => - setValue("keyword", e.target.value) - } - size="lg" - /> */} - setKeyword(e.target.value)} + size="lg" + /> + + + ) : ( + + ) + } /> ); diff --git a/src/lib/stores/account.ts b/src/lib/stores/account.ts index a35570452..6e2415fa4 100644 --- a/src/lib/stores/account.ts +++ b/src/lib/stores/account.ts @@ -78,4 +78,21 @@ export class AccountStore { isAccountSaved(address: Addr): boolean { return this.savedAccounts[this.userKey]?.includes(address) ?? false; } + + removeSavedAccount(address: Addr): void { + this.savedAccounts[this.userKey] = this.savedAccounts[this.userKey]?.filter( + (each) => each !== address + ); + } + + getSavedAccounts(): AccountLocalInfo[] { + const savedAccountsByUserKey = this.savedAccounts[this.userKey] ?? []; + return savedAccountsByUserKey + .map((address) => ({ + address, + name: this.getAccountLocalInfo(address)?.name, + description: this.getAccountLocalInfo(address)?.description, + })) + .reverse(); + } } diff --git a/src/lib/styles/theme/components/button.ts b/src/lib/styles/theme/components/button.ts index c77f4e84e..46d645110 100644 --- a/src/lib/styles/theme/components/button.ts +++ b/src/lib/styles/theme/components/button.ts @@ -326,6 +326,22 @@ export const Button: ComponentStyleConfig = { hoverBg: gray800, activeBg: "transparent", }), + "ghost-gray-icon": generateStyle({ + basic: { + color: "gray.400", + "> svg": { + color: "gray.600", + }, + }, + disabled: { + color: gray500, + "> svg": { + color: gray500, + }, + }, + hoverBg: gray800, + activeBg: "transparent", + }), "ghost-error": generateStyle({ basic: { color: "error.main" }, disabled: { color: "error.light" },