From a691fa9342aec94d1c1eb508152f839911a45051 Mon Sep 17 00:00:00 2001 From: MananTank Date: Tue, 25 Feb 2025 13:19:02 +0000 Subject: [PATCH] [TOOL-3508] Dashboard: Engine general Page UI improvements, update engine instance header layout (#6324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR focuses on refactoring and enhancing the `engine` module of the dashboard application. It introduces new components, improves UI elements, and modifies existing functionalities to streamline engine management processes. ### Detailed summary - Removed unused imports across multiple files. - Updated CSS variables for text colors. - Enhanced `EngineImportPage` with improved form handling and validation using `zod`. - Introduced `EngineInfoCard` and `EngineStatusBadge` components. - Refactored `EngineInstancesTable` to improve instance management. - Added new modals for editing and deleting engine instances. - Implemented better state management and routing for engine actions. - Improved UI layout and styling for various components. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- apps/dashboard/src/@/components/ui/input.tsx | 2 +- .../src/@/components/ui/tracked-link.tsx | 20 + apps/dashboard/src/@/styles/globals.css | 2 +- .../src/@3rdweb-sdk/react/hooks/useEngine.ts | 128 +-- .../components/EcosystemSlugLayout.tsx | 1 - .../[team_slug]/(team)/~/ecosystem/page.tsx | 1 - .../(team)/~/engine/(general)/_components.tsx | 123 ++ .../(team)/~/engine/(general)/create/page.tsx | 10 +- .../~/engine/(general)/create/tier-card.tsx | 10 +- .../import/EngineImportPage.stories.tsx | 40 + .../(general)/import/EngineImportPage.tsx | 256 +++-- .../(team)/~/engine/(general)/import/page.tsx | 20 +- .../(team)/~/engine/(general)/layout.tsx | 32 +- .../engine-instances-table.stories.tsx | 124 ++ .../overview/engine-instances-table.tsx | 1017 +++++++++++------ .../engine/(general)/overview/engine-list.tsx | 178 +-- .../(team)/~/engine/(general)/page.tsx | 14 +- .../[engineId]/_components/version.tsx | 6 +- .../~/engine/(instance)/[engineId]/layout.tsx | 110 +- .../overview/components/engine-overview.tsx | 7 +- .../components/wallet-credentials-table.tsx | 1 - .../[team_slug]/(team)/~/engine/layout.tsx | 7 - .../members/InviteSection.stories.tsx | 1 - .../HeaderLoggedOut/HeaderLoggedOut.tsx | 1 - 24 files changed, 1280 insertions(+), 831 deletions(-) create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/_components.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/EngineImportPage.stories.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.stories.tsx delete mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/layout.tsx diff --git a/apps/dashboard/src/@/components/ui/input.tsx b/apps/dashboard/src/@/components/ui/input.tsx index 9c07c53ae06..dec5d151cfd 100644 --- a/apps/dashboard/src/@/components/ui/input.tsx +++ b/apps/dashboard/src/@/components/ui/input.tsx @@ -11,7 +11,7 @@ const Input = React.forwardRef( & { category: string; @@ -24,3 +25,22 @@ export function TrackedLinkTW(props: TrackedLinkProps) { /> ); } + +export function TrackedUnderlineLink(props: TrackedLinkProps) { + const trackEvent = useTrack(); + const { category, label, trackingProps, ...restProps } = props; + + return ( + { + trackEvent({ category, action: "click", label, ...trackingProps }); + props.onClick?.(e); + }} + className={cn( + "underline decoration-muted-foreground/50 decoration-dotted underline-offset-[5px] hover:text-foreground hover:decoration-foreground hover:decoration-solid", + restProps.className, + )} + /> + ); +} diff --git a/apps/dashboard/src/@/styles/globals.css b/apps/dashboard/src/@/styles/globals.css index 5f62253b3c6..87bba45a09a 100644 --- a/apps/dashboard/src/@/styles/globals.css +++ b/apps/dashboard/src/@/styles/globals.css @@ -31,7 +31,7 @@ --link-foreground: 221.21deg 83.19% 53.33%; --success-text: 142.09 70.56% 35.29%; --warning-text: 38 92% 40%; - --destructive-text: 357.15deg 100% 68.72%; + --destructive-text: 360 72% 60%; /* Borders */ --border: 0 0% 85%; diff --git a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts index b826c04686d..c591762d698 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts @@ -230,101 +230,71 @@ export function useEngineUpdateDeployment() { }); } -export function useEngineRemoveFromDashboard() { - const address = useActiveAccount()?.address; - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (instanceId: string) => { - invariant(instanceId, "instance is required"); - - const res = await apiServerProxy({ - pathname: `/v1/engine/${instanceId}`, - method: "DELETE", - }); - - if (!res.ok) { - throw new Error(res.error); - } - }, +export type RemoveEngineFromDashboardIParams = { + instanceId: string; +}; - onSuccess: () => { - return queryClient.invalidateQueries({ - queryKey: engineKeys.instances(address || ""), - }); - }, +export async function removeEngineFromDashboard({ + instanceId, +}: RemoveEngineFromDashboardIParams) { + const res = await apiServerProxy({ + pathname: `/v1/engine/${instanceId}`, + method: "DELETE", }); + + if (!res.ok) { + throw new Error(res.error); + } } -export interface DeleteCloudHostedInput { +export type DeleteCloudHostedEngineParams = { deploymentId: string; reason: "USING_SELF_HOSTED" | "TOO_EXPENSIVE" | "MISSING_FEATURES" | "OTHER"; feedback: string; -} - -export function useEngineDeleteCloudHosted() { - const address = useActiveAccount()?.address; - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - deploymentId, - reason, - feedback, - }: DeleteCloudHostedInput) => { - const res = await apiServerProxy({ - pathname: `/v2/engine/deployments/${deploymentId}/infrastructure/delete`, - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ reason, feedback }), - }); - - if (!res.ok) { - throw new Error(res.error); - } - }, +}; - onSuccess: () => { - return queryClient.invalidateQueries({ - queryKey: engineKeys.instances(address || ""), - }); - }, +export async function deleteCloudHostedEngine({ + deploymentId, + reason, + feedback, +}: DeleteCloudHostedEngineParams) { + const res = await apiServerProxy({ + pathname: `/v2/engine/deployments/${deploymentId}/infrastructure/delete`, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ reason, feedback }), }); + + if (!res.ok) { + throw new Error(res.error); + } } -export interface EditEngineInstanceInput { +export type EditEngineInstanceParams = { instanceId: string; name: string; url: string; -} - -export function useEngineEditInstance() { - const address = useActiveAccount()?.address; - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ instanceId, name, url }: EditEngineInstanceInput) => { - const res = await apiServerProxy({ - pathname: `/v1/engine/${instanceId}`, - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ name, url }), - }); +}; - if (!res.ok) { - throw new Error(res.error); - } - }, - onSuccess: () => { - return queryClient.invalidateQueries({ - queryKey: engineKeys.instances(address || ""), - }); - }, +export async function editEngineInstance({ + instanceId, + name, + url, +}: EditEngineInstanceParams) { + const res = await apiServerProxy({ + pathname: `/v1/engine/${instanceId}`, + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ name, url }), }); + + if (!res.ok) { + throw new Error(res.error); + } } export type Transaction = { diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx index b53977e5974..6871ac0fc7e 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx @@ -1,5 +1,4 @@ import { SidebarLayout } from "@/components/blocks/SidebarLayout"; -import {} from "@/constants/cookie"; import { redirect } from "next/navigation"; import { getAuthToken } from "../../../../../../../../api/lib/getAuthToken"; import { fetchEcosystem } from "../../../utils/fetchEcosystem"; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/page.tsx index 880786776af..ca2f6dccfcd 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/page.tsx @@ -1,5 +1,4 @@ import { Button } from "@/components/ui/button"; -import {} from "@/constants/cookie"; import { ArrowRightIcon, ExternalLinkIcon } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/_components.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/_components.tsx new file mode 100644 index 00000000000..a4f019cb7d9 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/_components.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + ArrowRightIcon, + DownloadIcon, + ExternalLinkIcon, + PlusIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useTrack } from "../../../../../../../hooks/analytics/useTrack"; + +export function CreateEngineLink(props: { + label: string; + engineLinkPrefix: string; +}) { + const trackEvent = useTrack(); + + return ( + + ); +} + +export function ImportEngineLink(props: { + label: string; + engineLinkPrefix: string; +}) { + const trackEvent = useTrack(); + + return ( + + ); +} + +export function EngineInfoCard(props: { team_slug: string }) { + const engineLinkPrefix = `/team/${props.team_slug}/~/engine`; + const trackEvent = useTrack(); + + return ( +
+
+

+ Your scalable web3 backend server +

+ +
+ +
    +
  • Read, write, and deploy contracts at production scale
  • +
  • + Reliably parallelize and retry transactions with gas & nonce + management +
  • +
  • Securely manage backend wallets
  • +
  • Built-in support for account abstraction, relayers, and more
  • +
+
+ +
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/page.tsx index df10460d08f..e738f0d947e 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/page.tsx @@ -2,16 +2,16 @@ import { EngineTierCard } from "./tier-card"; export default function Page() { return ( -
-

+
+

Choose an Engine deployment

-

- Host Engine on thirdweb with no setup or maintenance required. +

+ Host Engine on thirdweb with no setup or maintenance required

-
+
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card.tsx index 5d1fb794576..54d9b6cabf7 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/create/tier-card.tsx @@ -2,6 +2,7 @@ import { redirectToCheckout } from "@/actions/billing"; import { CheckoutButton } from "@/components/billing"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; import { Button } from "@/components/ui/button"; import type { EngineTier } from "@3rdweb-sdk/react/hooks/useEngine"; import { Flex, Spacer } from "@chakra-ui/react"; @@ -9,6 +10,7 @@ import { useTrack } from "hooks/analytics/useTrack"; import { CheckIcon } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; +import { useState } from "react"; interface EngineTierCardConfig { name: string; @@ -56,6 +58,7 @@ export const EngineTierCard = ({ isPrimaryCta?: boolean; ctaText?: string; }) => { + const [isRoutePending, startRouteTransition] = useState(false); const trackEvent = useTrack(); const params = useParams<{ team_slug: string }>(); const { name, monthlyPriceUsd } = ENGINE_TIER_CARD_CONFIG[tier]; @@ -126,7 +129,9 @@ export const EngineTierCard = ({ variant={isPrimaryCta ? "default" : "outline"} asChild > - {ctaText ?? defaultCtaText} + + {ctaText ?? defaultCtaText} + ) : ( { + startRouteTransition(true); trackEvent({ category: "engine", action: "click", @@ -148,6 +155,7 @@ export const EngineTierCard = ({ variant={isPrimaryCta ? "default" : "outline"} redirectToCheckout={redirectToCheckout} > + {isRoutePending && } {ctaText ?? defaultCtaText} )} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/EngineImportPage.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/EngineImportPage.stories.tsx new file mode 100644 index 00000000000..1c3d86ea23c --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/EngineImportPage.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { EngineImportCardUI } from "./EngineImportPage"; + +const meta: Meta = { + title: "Engine/general/import", + component: EngineImportCardUI, + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + prefillImportUrl: undefined, + importEngine: async (params) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log("importEngine", params); + }, + }, +}; + +export const WithPrefillUrl: Story = { + args: { + prefillImportUrl: "https://engine.example.com", + importEngine: async (params) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log("importEngine", params); + }, + }, +}; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/EngineImportPage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/EngineImportPage.tsx index fcc3f498125..67a6b6c8b3d 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/EngineImportPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/EngineImportPage.tsx @@ -3,67 +3,89 @@ import { apiServerProxy } from "@/actions/proxies"; import { Spinner } from "@/components/ui/Spinner/Spinner"; import { Button } from "@/components/ui/button"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { TrackedLinkTW } from "@/components/ui/tracked-link"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { FormControl } from "@chakra-ui/react"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; -import { - CircleAlertIcon, - CloudDownloadIcon, - ExternalLinkIcon, -} from "lucide-react"; +import { CircleAlertIcon, DownloadIcon, ExternalLinkIcon } from "lucide-react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { FormLabel } from "tw-components"; +import { z } from "zod"; + +const formSchema = z.object({ + name: z.string().min(1, "Name is required"), + url: z.string().url("Please enter a valid URL").min(1, "URL is required"), +}); + +type ImportEngineParams = z.infer; + +async function importEngine(data: ImportEngineParams) { + // Instance URLs should end with a /. + const url = data.url.endsWith("/") ? data.url : `${data.url}/`; + + const res = await apiServerProxy({ + pathname: "/v1/engine", + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: data.name, + url, + }), + }); -type ImportEngineInput = { - name: string; - url: string; -}; + if (!res.ok) { + throw new Error(res.error); + } +} -export const EngineImportPage = (props: { - importUrl?: string; +export function EngineImportCard(props: { + prefillImportUrl: string | undefined; teamSlug: string; -}) => { - const { importUrl } = props; +}) { const router = useDashboardRouter(); - const form = useForm({ - defaultValues: { + return ( + { + await importEngine(params); + router.push(`/team/${props.teamSlug}/~/engine`); + }} + /> + ); +} + +export function EngineImportCardUI(props: { + prefillImportUrl: string | undefined; + importEngine: (params: ImportEngineParams) => Promise; +}) { + const form = useForm({ + resolver: zodResolver(formSchema), + values: { name: "", - url: importUrl ? decodeURIComponent(importUrl) : undefined, + url: props.prefillImportUrl || "", }, }); const importMutation = useMutation({ - mutationFn: async (data: ImportEngineInput) => { - // Instance URLs should end with a /. - const url = data.url.endsWith("/") ? data.url : `${data.url}/`; - - const res = await apiServerProxy({ - pathname: "/v1/engine", - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: data.name, - url, - }), - }); - - if (!res.ok) { - throw new Error(res.error); - } - }, + mutationFn: props.importEngine, }); - const onSubmit = async (data: ImportEngineInput) => { + const onSubmit = async (data: ImportEngineParams) => { try { await importMutation.mutateAsync(data); toast.success("Engine imported successfully"); - router.push(`/team/${props.teamSlug}/~/engine`); } catch (e) { const message = e instanceof Error ? e.message : undefined; toast.error( @@ -76,79 +98,93 @@ export const EngineImportPage = (props: { }; return ( -
-

- Import Engine Instance -

- -
- -

- Import an Engine instance hosted on your infrastructure. -

- -
- - - Get help setting up Engine for free - - - -
- -
-
- - Name - - - - - URL - -
- +
+ + + {/* Card */} +
+
+

+ Import Engine Instance +

+

- Do not import a URL you do not recognize. + Import an Engine instance hosted on your infrastructure

+ +
+ + + Get help setting up Engine for free + + +
+ +
+ ( + + Name + + + + + + )} + /> + + ( + + URL + + + + +
+ +

+ Do not import a URL you do not recognize. +

+
+
+ )} + />
- -
- -
- - - +
+ +
+ +
+ +
+ +
); -}; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/page.tsx index c7a6c8f7193..89d16708101 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/import/page.tsx @@ -1,19 +1,27 @@ -import { EngineImportPage } from "./EngineImportPage"; +import { EngineImportCard } from "./EngineImportPage"; export default async function Page(props: { params: Promise<{ team_slug: string }>; searchParams: Promise<{ - importUrl?: string; + importUrl?: string | string[]; }>; }) { const [params, searchParams] = await Promise.all([ props.params, props.searchParams, ]); + + const importUrl = + typeof searchParams.importUrl === "string" + ? searchParams.importUrl + : undefined; + return ( - +
+ +
); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/layout.tsx index d64041c92b6..30f761bdec9 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/layout.tsx @@ -1,5 +1,6 @@ import type { SidebarLink } from "@/components/blocks/Sidebar"; import { SidebarLayout } from "@/components/blocks/SidebarLayout"; +import { CreateEngineLink, ImportEngineLink } from "./_components"; export default async function Layout(props: { params: Promise<{ @@ -11,21 +12,44 @@ export default async function Layout(props: { const linkPrefix = `/team/${params.team_slug}/~/engine`; const sidebarLinks: SidebarLink[] = [ { - label: "Overview", + label: "Engine Instances", href: `${linkPrefix}`, exactMatch: true, }, { - label: "Create", + label: "Create Engine", href: `${linkPrefix}/create`, }, { - label: "Import", + label: "Import Engine", href: `${linkPrefix}/import`, }, ]; return ( - {props.children} +
+ {/* header */} +
+
+

Engines

+
+ + + +
+
+
+ + {/* sidebar layout */} + + {props.children} + +
); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.stories.tsx new file mode 100644 index 00000000000..aaf3b3fbae8 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.stories.tsx @@ -0,0 +1,124 @@ +import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Toaster } from "sonner"; +import { EngineInstancesTableUI } from "./engine-instances-table"; + +const meta: Meta = { + title: "Engine/general/instances", + component: Story, + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + }, + }, + decorators: [ + (StoryInstance) => ( +
+ + +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +function createEngineInstanceStub( + name: string, + status: EngineInstance["status"], +): EngineInstance { + const engineId = `${name.toLowerCase().replace(/\s+/g, "-")}-engine`; + return { + id: engineId, + name, + url: `https://${engineId}.example.com`, + status, + deploymentId: status === "active" ? "dep-123" : undefined, + accountId: "acc-123", + lastAccessedAt: new Date().toISOString(), + }; +} + +const cloudHostedActiveEngineInstance = createEngineInstanceStub( + "Cloud Hosted Engine", + "active", +); +const selfHostedActiveEngineInstance = createEngineInstanceStub( + "Self Hosted Engine", + "active", +); +const pendingEngineInstance = createEngineInstanceStub( + "Staging Engine", + "requested", +); +const deployingEngineInstance = createEngineInstanceStub( + "Test Engine", + "deploying", +); +const deploymentFailedEngineInstance = createEngineInstanceStub( + "Deployment Failed Engine", + "deploymentFailed", +); +const paymentFailedEngineInstance = createEngineInstanceStub( + "Failed Engine", + "paymentFailed", +); + +export const MultipleInstances: Story = { + args: { + instances: [ + // active + cloudHostedActiveEngineInstance, + selfHostedActiveEngineInstance, + // others + pendingEngineInstance, + deployingEngineInstance, + paymentFailedEngineInstance, + deploymentFailedEngineInstance, + ], + engineLinkPrefix: "/team/test/engine", + }, +}; + +export const NoInstances: Story = { + args: { + instances: [], + engineLinkPrefix: "/team/test/engine", + }, +}; + +export const OneInstance: Story = { + args: { + instances: [cloudHostedActiveEngineInstance], + engineLinkPrefix: "/team/test/engine", + }, +}; + +function Story( + props: Omit< + React.ComponentProps, + | "deleteCloudHostedEngine" + | "editEngineInstance" + | "removeEngineFromDashboard" + >, +) { + return ( + { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log("deleteCloudHostedEngine", params); + }} + editEngineInstance={async (params) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log("editEngineInstance", params); + }} + removeEngineFromDashboard={async (params) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log("removeEngineFromDashboard", params); + }} + {...props} + /> + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.tsx index 23e362ac416..c5d97b25b46 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(general)/overview/engine-instances-table.tsx @@ -1,205 +1,389 @@ import { Spinner } from "@/components/ui/Spinner/Spinner"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; +import { Badge, type BadgeProps } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; import { Dialog, DialogContent, - DialogFooter, + DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; -import { ToolTipLabel } from "@/components/ui/tooltip"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { - type DeleteCloudHostedInput, - type EditEngineInstanceInput, + type DeleteCloudHostedEngineParams, + type EditEngineInstanceParams, type EngineInstance, - useEngineDeleteCloudHosted, - useEngineEditInstance, - useEngineRemoveFromDashboard, + type RemoveEngineFromDashboardIParams, + deleteCloudHostedEngine, + editEngineInstance, + removeEngineFromDashboard, } from "@3rdweb-sdk/react/hooks/useEngine"; -import { FormControl, Radio, RadioGroup } from "@chakra-ui/react"; -import { DialogDescription } from "@radix-ui/react-dialog"; -import { createColumnHelper } from "@tanstack/react-table"; -import { TWTable } from "components/shared/TWTable"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { useTrack } from "hooks/analytics/useTrack"; import { + CheckIcon, CircleAlertIcon, InfoIcon, PencilIcon, Trash2Icon, - TriangleAlertIcon, } from "lucide-react"; +import { MoreHorizontalIcon } from "lucide-react"; import Link from "next/link"; -import { type ReactNode, useState } from "react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import invariant from "tiny-invariant"; -import { FormLabel } from "tw-components"; +import { z } from "zod"; + +type DeletedCloudHostedEngine = ( + params: DeleteCloudHostedEngineParams, +) => Promise; -interface EngineInstancesTableProps { +type EditedEngineInstance = (params: EditEngineInstanceParams) => Promise; + +type RemovedEngineFromDashboard = ( + params: RemoveEngineFromDashboardIParams, +) => Promise; + +export function EngineInstancesTable(props: { instances: EngineInstance[]; engineLinkPrefix: string; +}) { + const router = useDashboardRouter(); + + return ( + { + await deleteCloudHostedEngine(params); + router.refresh(); + }} + editEngineInstance={async (params) => { + await editEngineInstance(params); + router.refresh(); + }} + removeEngineFromDashboard={async (params) => { + await removeEngineFromDashboard(params); + router.refresh(); + }} + /> + ); } -export const EngineInstancesTable: React.FC = ({ - instances, - engineLinkPrefix, -}) => { +export function EngineInstancesTableUI(props: { + instances: EngineInstance[]; + engineLinkPrefix: string; + deleteCloudHostedEngine: DeletedCloudHostedEngine; + editEngineInstance: EditedEngineInstance; + removeEngineFromDashboard: RemovedEngineFromDashboard; +}) { + return ( +
+

+ Engine Instances +

+ + + + + + Engine Instance + Actions + + + + {props.instances.map((instance) => ( + + ))} + +
+ + {props.instances.length === 0 && ( +
+

No engine instances found.

+
+ )} +
+
+ ); +} + +function EngineInstanceRow(props: { + instance: EngineInstance; + engineLinkPrefix: string; + deleteCloudHostedEngine: DeletedCloudHostedEngine; + editEngineInstance: EditedEngineInstance; + removeEngineFromDashboard: RemovedEngineFromDashboard; +}) { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false); - const trackEvent = useTrack(); - const router = useDashboardRouter(); + const { instance, engineLinkPrefix } = props; - const [instanceToUpdate, setInstanceToUpdate] = useState< - EngineInstance | undefined - >(); - - const columnHelper = createColumnHelper(); - const columns = [ - columnHelper.accessor("id", { - header: "Engine Instances", - cell: (cell) => { - const { id, name, url, status } = cell.row.original; - - let badge: ReactNode | undefined; - if (status === "requested") { - badge = ( - -
- - - Pending - -
-
- ); - } else if (status === "deploying") { - badge = ( - -
- - - Deploying - -
-
- ); - } else if (status === "paymentFailed") { - badge = ( - + return ( + <> + + +
+
+ + +
+ {instance.status !== "active" && (
- - - Payment Failed - -
- - ); - } - - return ( -
- {badge ? ( -
-

{name}

- {badge} -
- ) : ( -
- - {name} - -

{url}

+
)}
- ); - }, - }), - ]; - - return ( - <> - , - text: "Edit", - onClick: (instance) => { - trackEvent({ - category: "engine", - action: "edit", - label: "open-modal", - }); - setInstanceToUpdate(instance); - setIsEditModalOpen(true); - }, - }, - { - icon: , - text: "Delete", - onClick: (instance) => { - trackEvent({ - category: "engine", - action: "remove", - label: "open-modal", - }); - setInstanceToUpdate(instance); - setIsRemoveModalOpen(true); - }, - isDestructive: true, - }, - ]} - bodyRowClassName="hover:bg-accent/50" - bodyRowLinkBox + + + setIsEditModalOpen(true)} + onRemove={() => setIsRemoveModalOpen(true)} + /> + + + + - {instanceToUpdate && ( - router.refresh()} - /> - )} + + + ); +} - {instanceToUpdate && ( - router.refresh()} - /> +function InstanceNameLink(props: { + instance: EngineInstance; + engineLinkPrefix: string; +}) { + const name = ( + + {props.instance.name} + + ); + return ( +
+ {props.instance.status === "requested" || + props.instance.status === "deploying" || + props.instance.status === "deploymentFailed" || + props.instance.status === "paymentFailed" ? ( + {name} + ) : ( + + {name} + )} - +
); +} + +function EngineURL(props: { url: string }) { + const cleanedURL = props.url.endsWith("/") + ? props.url.slice(0, -1) + : props.url; + + return

{cleanedURL}

; +} + +const engineStatusMeta: Record< + EngineInstance["status"], + { + label: string; + variant: BadgeProps["variant"]; + icon: React.FC<{ className?: string }>; + } +> = { + requested: { + label: "Pending", + variant: "outline", + icon: Spinner, + }, + deploying: { + label: "Deploying", + variant: "default", + icon: Spinner, + }, + active: { + label: "Active", + variant: "default", + icon: CheckIcon, + }, + pending: { + label: "Pending", + variant: "outline", + icon: Spinner, + }, + paymentFailed: { + label: "Payment Failed", + variant: "destructive", + icon: CircleAlertIcon, + }, + deploymentFailed: { + label: "Deployment Failed", + variant: "destructive", + icon: CircleAlertIcon, + }, }; -const EditModal = (props: { +function EngineStatusBadge(props: { + status: EngineInstance["status"]; +}) { + const statusMeta = engineStatusMeta[props.status]; + return ( + + + {statusMeta.label} + + ); +} + +function EngineActionsDropdown(props: { + instance: EngineInstance; + onEdit: (instance: EngineInstance) => void; + onRemove: (instance: EngineInstance) => void; +}) { + const trackEvent = useTrack(); + const canDelete = + props.instance.status === "paymentFailed" || + props.instance.status === "deploymentFailed" || + props.instance.status === "active" || + !!props.instance.deploymentId; + + return ( + + + + + + { + trackEvent({ + category: "engine", + action: "edit", + label: "open-modal", + }); + props.onEdit(props.instance); + }} + > + + Edit + + + { + trackEvent({ + category: "engine", + action: "remove", + label: "open-modal", + }); + props.onRemove(props.instance); + }} + > + + Delete + + + + ); +} + +function EditModal(props: { open: boolean; onOpenChange: (open: boolean) => void; instance: EngineInstance; - refetch: () => void; -}) => { - const editInstance = useEngineEditInstance(); - const { onOpenChange, instance, open, refetch } = props; + editEngineInstance: EditedEngineInstance; +}) { + return ( + + + props.onOpenChange(false)} + /> + + + ); +} + +const editEngineFormSchema = z.object({ + name: z.string().min(1, "Name is required"), + url: z.string().url("Invalid URL"), +}); - const form = useForm({ +function EditModalContent(props: { + instance: EngineInstance; + editEngineInstance: EditedEngineInstance; + closeModal: () => void; +}) { + const editInstance = useMutation({ + mutationFn: props.editEngineInstance, + }); + const { instance } = props; + + const form = useForm>({ + resolver: zodResolver(editEngineFormSchema), values: { - instanceId: instance.id, name: instance.name, url: instance.url, }, @@ -207,151 +391,182 @@ const EditModal = (props: { }); return ( - - - - - Edit Engine Instance - - - -
- editInstance.mutate(data, { + + + editInstance.mutate( + { + instanceId: props.instance.id, + name: data.name, + url: data.url, + }, + { onSuccess: () => { toast.success("Engine updated successfully"); - refetch(); - onOpenChange(false); + props.closeModal(); }, onError: () => { toast.error("Failed to update Engine"); }, - }), - )} - > -
- - Name - - - - URL - - -
+ }, + ), + )} + > +
+ + Edit Engine Instance + + + ( + + Name + + + + + + )} + /> - - - - - - -
+ ( + + URL + + + + + + )} + /> +
+ +
+ + +
+ + ); -}; +} -const RemoveModal = (props: { +function RemoveModal(props: { instance: EngineInstance; open: boolean; onOpenChange: (open: boolean) => void; - refetch: () => void; -}) => { - const { instance, open, onOpenChange, refetch } = props; + deleteCloudHostedEngine: DeletedCloudHostedEngine; + removeEngineFromDashboard: RemovedEngineFromDashboard; +}) { + const { instance, open, onOpenChange } = props; return ( - + {instance.status === "paymentFailed" || + instance.status === "deploymentFailed" || (instance.status === "active" && !instance.deploymentId) ? ( - onOpenChange(false)} + removeEngineFromDashboard={props.removeEngineFromDashboard} /> - ) : ( - onOpenChange(false)} + deleteCloudHostedEngine={props.deleteCloudHostedEngine} /> - )} + ) : null} ); -}; +} -function RemoveFromDashboardModalContent(props: { - refetch: () => void; +function RemoveEngineFromDashboardModalContent(props: { instance: EngineInstance; close: () => void; + removeEngineFromDashboard: RemovedEngineFromDashboard; }) { - const { refetch, instance, close } = props; - const removeFromDashboard = useEngineRemoveFromDashboard(); + const { instance, close } = props; + const removeFromDashboard = useMutation({ + mutationFn: props.removeEngineFromDashboard, + }); return ( - <> - - - Remove Engine - - - - Are you sure you want to remove{" "} - {instance.name} from - your dashboard? - - - - - - This action does not modify your Engine infrastructure. - - You can re-add it at any time. - - - - - +
+
+ + Remove Engine from Dashboard + + + Are you sure you want to remove{" "} + {instance.name} from + your dashboard? + + + + +
+ + + + + This action does not modify your Engine infrastructure + + + You can import engine to dashboard again later + + +
+ +
- - +
+
); } -function DeleteSubscriptionModalContent(props: { - refetch: () => void; +const deleteEngineReasons: Array<{ + value: DeleteCloudHostedEngineParams["reason"]; + label: string; +}> = [ + { value: "USING_SELF_HOSTED", label: "Migrating to self-hosted" }, + { value: "TOO_EXPENSIVE", label: "Too expensive" }, + { value: "MISSING_FEATURES", label: "Missing features" }, + { value: "OTHER", label: "Other" }, +]; + +const deleteEngineFormSchema = z.object({ + reason: z.enum([ + "USING_SELF_HOSTED", + "TOO_EXPENSIVE", + "MISSING_FEATURES", + "OTHER", + ]), + feedback: z.string(), + confirmDeletion: z.boolean(), +}); + +function DeleteEngineSubscriptionModalContent(props: { instance: EngineInstance; close: () => void; + deleteCloudHostedEngine: DeletedCloudHostedEngine; }) { - const { refetch, instance, close } = props; - invariant( - instance.deploymentId, - "Instance must have a deploymentId to be cancelled.", - ); + const { instance, close } = props; + const deleteCloudHostedEngine = useMutation({ + mutationFn: props.deleteCloudHostedEngine, + }); - const deleteCloudHosted = useEngineDeleteCloudHosted(); - const [ackDeletion, setAckDeletion] = useState(false); - const form = useForm({ + const form = useForm>({ + resolver: zodResolver(deleteEngineFormSchema), defaultValues: { - deploymentId: instance.deploymentId, + feedback: "", + confirmDeletion: false, }, reValidateMode: "onChange", }); - const onSubmit = (data: DeleteCloudHostedInput) => { - deleteCloudHosted.mutate(data, { - onSuccess: () => { - toast.success("Deleting Engine. Please check again in a few minutes.", { - dismissible: true, - duration: 10000, - }); - - refetch(); - close(); + const onSubmit = (data: z.infer) => { + // unexpected state + if (!instance.deploymentId) { + toast.error("Can not delete this Engine instance", { + description: "Engine instance is missing deployment id", + }); + return; + } + + deleteCloudHostedEngine.mutate( + { + deploymentId: instance.deploymentId, + reason: data.reason, + feedback: data.feedback, }, - onError: () => { - toast.error( - "Error deleting Engine. Please visit https://thirdweb.com/support.", - ); + { + onSuccess: () => { + toast.success( + "Deleting Engine. Please check again in a few minutes.", + { + dismissible: true, + duration: 10000, + }, + ); + + close(); + }, + onError: () => { + toast.error( + "Error deleting Engine. Please visit https://thirdweb.com/support.", + ); + }, }, - }); + ); }; return (
- - - Permanently Delete Engine - - - -
- -

- This step will cancel your monthly subscription and immediately delete - all data and infrastructure for this Engine. -

- -
- -
- {/* Reason */} - - - Please share your feedback to help us improve Engine. - - -
- - Migrating to self-hosted - - - Too expensive - - - Missing features - - - Other - -
-
-
- -
- - {/* Feedback */} -