From 92fbb18ae975d3defc54b6903def9a3579e31517 Mon Sep 17 00:00:00 2001 From: MananTank Date: Mon, 14 Oct 2024 20:53:26 +0000 Subject: [PATCH] Move Deployed Contracts Page to App Router (#4995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem solved Short description of the bug fixed or feature added --- ## PR-Codex overview This PR focuses on enhancing the contract deployment and management features in the dashboard application. It introduces new components, improves layouts, and refines data handling for better user experience. ### Detailed summary - Updated layout in `deploy.tsx` and `layout.tsx` to use flexbox for better responsiveness. - Added `getAuthTokenWalletAddress` function in `getAuthToken.ts` for wallet address retrieval. - Introduced `DeployedContractsPage` and `DeployedContractsTable` components for contract management. - Refactored `DeployedContracts` component to accept contract list directly and handle loading state. - Added `GetStartedWithContractsDeploy` component for user onboarding. - Enhanced `DeployedContractsPageHeader` with import and deploy contract buttons. - Improved sorting logic in `getSortedDeployedContracts.tsx`. - Removed unused imports and cleaned up code for better readability. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .../dashboard/contracts/deploy/page.tsx | 19 ++++ .../dashboard/contracts/layout.tsx | 13 +++ apps/dashboard/src/app/(dashboard)/layout.tsx | 2 +- .../dashboard/src/app/api/lib/getAuthToken.ts | 16 +++ .../contracts/DeployedContractsPageHeader.tsx | 49 +++++++++ .../_components/DeployedContractsPage.tsx | 48 +++++++++ .../_components/DeployedContractsTable.tsx | 18 ++++ .../GetStartedWithContractsDeploy.tsx | 15 +-- .../getSortedDeployedContracts.tsx | 42 ++++++++ .../[project_slug]/contracts/layout.tsx | 2 +- .../[project_slug]/contracts/page.tsx | 38 +++---- .../tables/deployed-contracts.tsx | 102 +++++++----------- .../src/pages/dashboard/contracts/deploy.tsx | 44 -------- .../src/pages/profile/[profileAddress].tsx | 4 +- 14 files changed, 265 insertions(+), 147 deletions(-) create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/contracts/deploy/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/contracts/layout.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/DeployedContractsPageHeader.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/DeployedContractsPage.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/DeployedContractsTable.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/getSortedDeployedContracts.tsx delete mode 100644 apps/dashboard/src/pages/dashboard/contracts/deploy.tsx diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/contracts/deploy/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/contracts/deploy/page.tsx new file mode 100644 index 00000000000..289ef906956 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/contracts/deploy/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import { getAuthTokenWalletAddress } from "../../../../api/lib/getAuthToken"; +import { DeployedContractsPage } from "../../../../team/[team_slug]/[project_slug]/contracts/_components/DeployedContractsPage"; + +export default function Page() { + const accountAddress = getAuthTokenWalletAddress(); + if (!accountAddress) { + return redirect( + `/login?next=${encodeURIComponent("/dashboard/contracts/deploy")}`, + ); + } + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/contracts/layout.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/contracts/layout.tsx new file mode 100644 index 00000000000..27dcd951ac8 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/contracts/layout.tsx @@ -0,0 +1,13 @@ +import { BillingAlerts } from "../../../../components/settings/Account/Billing/alerts/Alert"; +import { ContractsSidebarLayout } from "../../../../core-ui/sidebar/contracts"; + +export default function Layout(props: { + children: React.ReactNode; +}) { + return ( + + + {props.children} + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/layout.tsx b/apps/dashboard/src/app/(dashboard)/layout.tsx index 3b08e193fc2..c89aefb25ac 100644 --- a/apps/dashboard/src/app/(dashboard)/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/layout.tsx @@ -7,7 +7,7 @@ export default function DashboardLayout(props: { children: React.ReactNode }) {
-
{props.children}
+
{props.children}
diff --git a/apps/dashboard/src/app/api/lib/getAuthToken.ts b/apps/dashboard/src/app/api/lib/getAuthToken.ts index 36c4b4232a2..24f3174458d 100644 --- a/apps/dashboard/src/app/api/lib/getAuthToken.ts +++ b/apps/dashboard/src/app/api/lib/getAuthToken.ts @@ -10,3 +10,19 @@ export function getAuthToken() { return token; } + +export function getAuthTokenWalletAddress() { + const cookiesManager = cookies(); + const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value; + if (!activeAccount) { + return null; + } + + const token = cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value; + + if (token) { + return activeAccount; + } + + return null; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/DeployedContractsPageHeader.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/DeployedContractsPageHeader.tsx new file mode 100644 index 00000000000..7949da8aa89 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/DeployedContractsPageHeader.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { DownloadIcon, PlusIcon } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { Button } from "../../../../../@/components/ui/button"; +import { ImportModal } from "../../../../../components/contract-components/import-contract/modal"; + +export function DeployedContractsPageHeader() { + const [importModalOpen, setImportModalOpen] = useState(false); + + return ( +
+ { + setImportModalOpen(false); + }} + /> +
+
+

+ Your contracts +

+

+ The list of contract instances that you have deployed or imported + with thirdweb across all networks +

+
+
+ + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/DeployedContractsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/DeployedContractsPage.tsx new file mode 100644 index 00000000000..f4d559c9995 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/DeployedContractsPage.tsx @@ -0,0 +1,48 @@ +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Suspense } from "react"; +import { ClientOnly } from "../../../../../../components/ClientOnly/ClientOnly"; +import { DeployedContractsPageHeader } from "../DeployedContractsPageHeader"; +import { DeployedContractsTable } from "./DeployedContractsTable"; +import { GetStartedWithContractsDeploy } from "./GetStartedWithContractsDeploy"; +import { getSortedDeployedContracts } from "./getSortedDeployedContracts"; + +export function DeployedContractsPage(props: { + address: string; + className?: string; +}) { + return ( +
+ +
+ }> + + +
+ ); +} + +async function DeployedContractsPageAsync(props: { + address: string; +}) { + const deployedContracts = await getSortedDeployedContracts({ + address: props.address, + }); + + if (deployedContracts.length === 0) { + return ; + } + + return ( + }> + + + ); +} + +function Loading() { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/DeployedContractsTable.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/DeployedContractsTable.tsx new file mode 100644 index 00000000000..8e69d6220fc --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/DeployedContractsTable.tsx @@ -0,0 +1,18 @@ +"use client"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import type { BasicContract } from "contract-ui/types/types"; +import { DeployedContracts } from "../../../../../../components/contract-components/tables/deployed-contracts"; + +export function DeployedContractsTable(props: { + contracts: BasicContract[]; +}) { + const router = useDashboardRouter(); + return ( + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/GetStartedWithContractsDeploy.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/GetStartedWithContractsDeploy.tsx index b16212281f9..29ee0c5474c 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/GetStartedWithContractsDeploy.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/GetStartedWithContractsDeploy.tsx @@ -1,26 +1,15 @@ "use client"; import { TabButtons } from "@/components/ui/tabs"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet"; import Image from "next/image"; import { useMemo, useState } from "react"; -import { useActiveAccount } from "thirdweb/react"; import { ImportModal } from "../../../../../../components/contract-components/import-contract/modal"; import { StepsCard } from "../../../../../../components/dashboard/StepsCard"; import { useTrack } from "../../../../../../hooks/analytics/useTrack"; export function GetStartedWithContractsDeploy() { - const address = useActiveAccount()?.address; const steps = useMemo( () => [ - { - title: "Connect your wallet to get started", - description: - "In order to interact with your contracts you need to connect an EVM compatible wallet.", - children: , - completed: !!address, - }, - { title: "Build, deploy or import a contract", description: @@ -29,7 +18,7 @@ export function GetStartedWithContractsDeploy() { completed: false, // because we only show this component if the user does not have any contracts }, ], - [address], + [], ); return ( @@ -139,7 +128,7 @@ const DeployOptions = () => {

{activeTabContent.title}

-

+

{activeTabContent.description}

diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/getSortedDeployedContracts.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/getSortedDeployedContracts.tsx new file mode 100644 index 00000000000..f7395fd0ee0 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/_components/getSortedDeployedContracts.tsx @@ -0,0 +1,42 @@ +import type { BasicContract } from "contract-ui/types/types"; +import { MULTICHAIN_REGISTRY_CONTRACT } from "../../../../../../constants/contracts"; +import { getAllMultichainRegistry } from "../../../../../../dashboard-extensions/common/read/getAllMultichainRegistry"; +import { fetchChain } from "../../../../../../utils/fetchChain"; + +export async function getSortedDeployedContracts(params: { + address: string; +}) { + const contracts = await getAllMultichainRegistry({ + contract: MULTICHAIN_REGISTRY_CONTRACT, + address: params.address, + }); + + const chainIds = Array.from(new Set(contracts.map((c) => c.chainId))); + const chains = ( + await Promise.allSettled( + chainIds.map((chainId) => fetchChain(chainId.toString())), + ) + ) + .filter((c) => c.status === "fulfilled") + .map((c) => c.value) + .filter((c) => c !== null); + + const mainnetContracts: BasicContract[] = []; + const testnetContracts: BasicContract[] = []; + + for (const contract of contracts) { + const chain = chains.find((chain) => contract.chainId === chain.chainId); + if (chain && chain.status !== "deprecated") { + if (chain.testnet) { + testnetContracts.push(contract); + } else { + mainnetContracts.push(contract); + } + } + } + + mainnetContracts.sort((a, b) => a.chainId - b.chainId); + testnetContracts.sort((a, b) => a.chainId - b.chainId); + + return [...mainnetContracts, ...testnetContracts]; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/layout.tsx index 38c1be3122f..500764ffd23 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/layout.tsx @@ -1,5 +1,5 @@ export default function Layout(props: { children: React.ReactNode; }) { - return
{props.children}
; + return
{props.children}
; } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/page.tsx index 4389c0b266b..c305fbeae6c 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/page.tsx @@ -1,31 +1,23 @@ -"use client"; -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { useAllContractList } from "@3rdweb-sdk/react/hooks/useRegistry"; -import { useActiveAccount } from "thirdweb/react"; -import { DeployedContracts } from "../../../../../components/contract-components/tables/deployed-contracts"; -import { GetStartedWithContractsDeploy } from "./_components/GetStartedWithContractsDeploy"; +import { redirect } from "next/navigation"; +import { getAuthTokenWalletAddress } from "../../../../api/lib/getAuthToken"; +import { DeployedContractsPage } from "./_components/DeployedContractsPage"; -export default function Page() { - const address = useActiveAccount()?.address; - const deployedContracts = useAllContractList(address); - const hasContracts = - deployedContracts.data && deployedContracts.data?.length > 0; +export default function Page(props: { + params: { team_slug: string; project_slug: string }; +}) { + const { team_slug, project_slug } = props.params; + const accountAddress = getAuthTokenWalletAddress(); - if (deployedContracts.isPending) { - return ( -
- -
+ if (!accountAddress) { + return redirect( + `/login?next=${encodeURIComponent(`/team/${team_slug}/${project_slug}/contracts`)}`, ); } return ( -
- {!hasContracts ? ( - - ) : ( - - )} -
+ ); } diff --git a/apps/dashboard/src/components/contract-components/tables/deployed-contracts.tsx b/apps/dashboard/src/components/contract-components/tables/deployed-contracts.tsx index 5cdb3d9c95a..331f94760e8 100644 --- a/apps/dashboard/src/components/contract-components/tables/deployed-contracts.tsx +++ b/apps/dashboard/src/components/contract-components/tables/deployed-contracts.tsx @@ -9,6 +9,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { SkeletonContainer } from "@/components/ui/skeleton"; import { Table, TableBody, @@ -26,13 +27,7 @@ import { import { ChainIcon } from "components/icons/ChainIcon"; import { NetworkSelectDropdown } from "components/selects/NetworkSelectDropdown"; import type { BasicContract } from "contract-ui/types/types"; -import { - DownloadIcon, - EllipsisVerticalIcon, - PlusIcon, - XIcon, -} from "lucide-react"; -import Link from "next/link"; +import { EllipsisVerticalIcon, XIcon } from "lucide-react"; import { memo, useEffect, useMemo, useState } from "react"; import { type Column, @@ -44,80 +39,42 @@ import { } from "react-table"; import { useAllChainsData } from "../../../hooks/chains/allChains"; import { useChainSlug } from "../../../hooks/chains/chainSlug"; -import { ImportModal } from "../import-contract/modal"; import { AsyncContractNameCell, AsyncContractTypeCell } from "./cells"; import { ShowMoreButton } from "./show-more-button"; interface DeployedContractsProps { - noHeader?: boolean; - contractListQuery: ReturnType; + contractList: ReturnType["data"]; + isPending: boolean; limit?: number; + onContractRemoved?: () => void; } export const DeployedContracts: React.FC = ({ - noHeader, - contractListQuery, + contractList, limit = 10, + isPending, + onContractRemoved, }) => { - const [importModalOpen, setImportModalOpen] = useState(false); - const chainIdsWithDeployments = useMemo(() => { const set = new Set(); // biome-ignore lint/complexity/noForEach: FIXME - contractListQuery.data.forEach((contract) => { + contractList.forEach((contract) => { set.add(contract.chainId); }); return [...set]; - }, [contractListQuery.data]); + }, [contractList]); return (
- {!noHeader && ( - <> - { - setImportModalOpen(false); - }} - /> -
-
-

- Your contracts -

-

- The list of contract instances that you have deployed or - imported with thirdweb across all networks -

-
-
- - -
-
- - )} - - {contractListQuery.data.length === 0 && contractListQuery.isFetched && ( + {contractList.length === 0 && !isPending && (
No contracts found
@@ -129,11 +86,13 @@ export const DeployedContracts: React.FC = ({ type RemoveFromDashboardButtonProps = { chainId: number; contractAddress: string; + onContractRemoved?: () => void; }; const RemoveFromDashboardButton: React.FC = ({ chainId, contractAddress, + onContractRemoved, }) => { const mutation = useRemoveContractMutation(); @@ -142,7 +101,14 @@ const RemoveFromDashboardButton: React.FC = ({ variant="ghost" onClick={(e) => { e.stopPropagation(); - mutation.mutate({ chainId, contractAddress }); + mutation.mutateAsync( + { chainId, contractAddress }, + { + onSuccess: () => { + onContractRemoved?.(); + }, + }, + ); }} disabled={mutation.isPending} className="!bg-background hover:!bg-accent gap-2" @@ -188,6 +154,7 @@ interface ContractTableProps { limit: number; chainIdsWithDeployments: number[]; loading: boolean; + onContractRemoved?: () => void; } const ContractTable: React.FC = ({ @@ -196,6 +163,7 @@ const ContractTable: React.FC = ({ limit, chainIdsWithDeployments, loading, + onContractRemoved, }) => { const { idToChain } = useAllChainsData(); @@ -236,9 +204,14 @@ const ContractTable: React.FC = ({ return (
-

- {cleanedChainName} -

+ { + return

{v}

; + }} + /> + {data?.testnet && ( Testnet @@ -285,6 +258,7 @@ const ContractTable: React.FC = ({ @@ -292,7 +266,7 @@ const ContractTable: React.FC = ({ }, }, ], - [chainIdsWithDeployments, idToChain], + [chainIdsWithDeployments, idToChain, onContractRemoved], ); const defaultColumn = useMemo( @@ -395,10 +369,12 @@ const ContractTable: React.FC = ({ const ContractTableRow = memo(({ row }: { row: Row }) => { const chainSlug = useChainSlug(row.original.chainId); const router = useDashboardRouter(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { key, ...rowProps } = row.getRowProps(); return ( { - const address = useActiveAccount()?.address; - const deployedContracts = useAllContractList(address); - - if (deployedContracts.isPending) { - return ( -
- -
- ); - } - - const hasContracts = - deployedContracts.data && deployedContracts.data?.length > 0; - - if (!hasContracts) { - return ; - } - - return ; -}; - -Contracts.getLayout = (page, props) => ( - - {page} - -); -Contracts.pageId = PageId.Contracts; - -export default Contracts; diff --git a/apps/dashboard/src/pages/profile/[profileAddress].tsx b/apps/dashboard/src/pages/profile/[profileAddress].tsx index 14f43a956c9..a2408cfaf50 100644 --- a/apps/dashboard/src/pages/profile/[profileAddress].tsx +++ b/apps/dashboard/src/pages/profile/[profileAddress].tsx @@ -177,8 +177,8 @@ const UserPage: ThirdwebNextPage = (props: UserPageProps) => { {ens.data?.address && ( )}