diff --git a/apps/deploy-web/next.config.js b/apps/deploy-web/next.config.js index b412fc8c79..124996c7cb 100644 --- a/apps/deploy-web/next.config.js +++ b/apps/deploy-web/next.config.js @@ -151,6 +151,22 @@ const nextConfig = { }, redirects: async () => { return [ + { + source: "/sdl-builder", + has: [{ type: "query", key: "id", value: "(?.+)" }], + destination: "/new-deployment/configure?userTemplateId=:userTemplateId", + permanent: true + }, + { + source: "/sdl-builder", + destination: "/new-deployment/configure", + permanent: true + }, + { + source: "/deploy-linux", + destination: "/new-deployment/configure?vm=true", + permanent: true + }, { source: "/deploy", destination: "/cloud-deploy", diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeployment/ConfigureDeployment.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeployment/ConfigureDeployment.spec.tsx index e392ec569a..354bee31ea 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeployment/ConfigureDeployment.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeployment/ConfigureDeployment.spec.tsx @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import sdlStore from "@src/store/sdlStore"; -import type { TemplateCreation } from "@src/types"; +import type { ITemplate, TemplateCreation } from "@src/types"; import { helloWorldTemplate } from "@src/utils/templates"; import type { DeploymentFlow } from "../useDeploymentFlow/useDeploymentFlow"; import type { DEPENDENCIES } from "./ConfigureDeployment"; @@ -141,6 +141,62 @@ describe(ConfigureDeployment.name, () => { expect(AutoDeployFlow).toHaveBeenCalledWith(expect.objectContaining({ sdl: helloWorldTemplate.content, flow: expect.anything() }), expect.anything()); }); + it("hydrates the form from the fetched user template's SDL and title", () => { + const { ConfigureDeploymentForm, useUserTemplate } = setup({ + userTemplateId: "user-1", + userTemplate: { isLoading: false, isSuccess: true, data: mock({ sdl: "user: sdl", title: "My Saved Template" }) } + }); + + expect(useUserTemplate).toHaveBeenCalledWith("user-1"); + expect(ConfigureDeploymentForm).toHaveBeenCalledWith( + expect.objectContaining({ initialSdl: "user: sdl", initialName: "My Saved Template" }), + expect.anything() + ); + }); + + it("shows a loading state while the user template is being fetched", () => { + const { ConfigureDeploymentForm } = setup({ userTemplateId: "user-1", userTemplate: { isLoading: true } }); + + expect(screen.getByRole("status")).toBeInTheDocument(); + expect(ConfigureDeploymentForm).not.toHaveBeenCalled(); + }); + + it("surfaces an error and falls back to a default when the user template can't be loaded", () => { + const { ConfigureDeploymentForm, enqueueSnackbar } = setup({ userTemplateId: "user-1", userTemplate: { isLoading: false, isError: true } }); + + expect(enqueueSnackbar).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ variant: "error" })); + expect(ConfigureDeploymentForm).toHaveBeenCalledWith(expect.objectContaining({ initialSdl: undefined }), expect.anything()); + }); + + it("surfaces an error when the user template isn't visible to the viewer", () => { + const { ConfigureDeploymentForm, enqueueSnackbar } = setup({ + userTemplateId: "user-1", + userTemplate: { isLoading: false, isSuccess: true, data: null } + }); + + expect(enqueueSnackbar).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ variant: "error" })); + expect(ConfigureDeploymentForm).toHaveBeenCalledWith(expect.objectContaining({ initialSdl: undefined }), expect.anything()); + }); + + it("restores the draft's persisted SDL and skips the user template fetch", () => { + const { ConfigureDeploymentForm, useUserTemplate } = setup({ userTemplateId: "user-1", persistedSdl: "restored: sdl" }); + + expect(useUserTemplate).toHaveBeenCalledWith(undefined); + expect(ConfigureDeploymentForm).toHaveBeenCalledWith(expect.objectContaining({ initialSdl: "restored: sdl" }), expect.anything()); + }); + + it("forwards the user template id into the form's intent", () => { + const { ConfigureDeploymentForm } = setup({ + userTemplateId: "user-1", + userTemplate: { isLoading: false, isSuccess: true, data: mock({ sdl: "user: sdl" }) } + }); + + expect(ConfigureDeploymentForm).toHaveBeenCalledWith( + expect.objectContaining({ intent: expect.objectContaining({ userTemplateId: "user-1" }) }), + expect.anything() + ); + }); + function setup(input: { templateId?: string | null; sdlStrategy?: string; @@ -149,6 +205,8 @@ describe(ConfigureDeployment.name, () => { persistedSdl?: string; persistedName?: string; template?: { isLoading?: boolean; isError?: boolean; data?: TemplateOutput }; + userTemplateId?: string; + userTemplate?: { isLoading?: boolean; isError?: boolean; isSuccess?: boolean; data?: ITemplate | null }; deploySdl?: TemplateCreation | null; vm?: boolean; }) { @@ -157,6 +215,7 @@ describe(ConfigureDeployment.name, () => { const DeploymentFlowProvider = vi.fn(({ children }) => <>{children({ flow: mock() })}); const enqueueSnackbar = vi.fn(); const usePublicTemplate = vi.fn(() => mock>(input.template as never)); + const useUserTemplate = vi.fn(() => mock>(input.userTemplate as never)); const save = vi.fn(); const clear = vi.fn(); const useConfigureDraft = vi.fn(() => @@ -171,6 +230,7 @@ describe(ConfigureDeployment.name, () => { const query: Record = {}; if (input.templateId) query.templateId = input.templateId; + if (input.userTemplateId) query.userTemplateId = input.userTemplateId; if (input.sdlStrategy) query["sdl-strategy"] = input.sdlStrategy; if (input.bidStrategy) query["bid-strategy"] = input.bidStrategy; if (input.vm) query.vm = "true"; @@ -184,6 +244,7 @@ describe(ConfigureDeployment.name, () => { DeploymentFlowProvider: DeploymentFlowProvider as never, ResumeDeploymentGuard: vi.fn(({ children }) => <>{children({ activeLeases: [] })}) as never, usePublicTemplate: usePublicTemplate as never, + useUserTemplate: useUserTemplate as never, useConfigureDraft: useConfigureDraft as never, useSearchParams: () => params as unknown as ReadonlyURLSearchParams, useParams: (() => ({})) as never, @@ -200,6 +261,6 @@ describe(ConfigureDeployment.name, () => { ); - return { ConfigureDeploymentForm, AutoDeployFlow, DeploymentFlowProvider, usePublicTemplate, useConfigureDraft, enqueueSnackbar }; + return { ConfigureDeploymentForm, AutoDeployFlow, DeploymentFlowProvider, usePublicTemplate, useUserTemplate, useConfigureDraft, enqueueSnackbar }; } }); diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeployment/ConfigureDeployment.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeployment/ConfigureDeployment.tsx index acb8e397b9..57956d8849 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeployment/ConfigureDeployment.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeployment/ConfigureDeployment.tsx @@ -8,7 +8,7 @@ import { NextSeo } from "next-seo"; import { useSnackbar } from "notistack"; import Layout from "@src/components/layout/Layout"; -import { usePublicTemplate } from "@src/queries/useTemplateQuery"; +import { usePublicTemplate, useTemplate } from "@src/queries/useTemplateQuery"; import sdlStore from "@src/store/sdlStore"; import type { TemplateCreation } from "@src/types"; import { hardcodedTemplates } from "@src/utils/templates"; @@ -28,6 +28,7 @@ export const DEPENDENCIES = { DeploymentFlowProvider, ResumeDeploymentGuard, usePublicTemplate, + useUserTemplate: useTemplate, useConfigureDraft, useSearchParams, useParams, @@ -44,10 +45,12 @@ type Props = { * `useConfigureDraft`) identifies a started session by a `draftId`: when present its persisted working SDL is restored * and the template is ignored; when absent an id is minted and written into the URL so a reload resumes the same draft. * Without a draft, a `templateId` is resolved the same way the legacy flow does: hardcoded templates (e.g. hello-world) - * carry their SDL inline and are matched by code, while everything else is fetched as a public gallery template; with - * neither, the carried-in `deploySdl` atom is used, except on a `vm=true` entry, which ignores the atom so a fresh - * Container-VM session always seeds deterministically. Keeping resolution here lets the form initialize synchronously - * from a single source — and lets a resume skip the template fetch. + * carry their SDL inline and are matched by code, while everything else is fetched as a public gallery template. A + * `userTemplateId` instead fetches the viewer's own saved template, which the API answers with an empty body when it + * isn't theirs to see — surfaced as the same "couldn't load" fallback as a failed request. With neither, the carried-in + * `deploySdl` atom is used, except on a `vm=true` entry, which ignores the atom so a fresh Container-VM session always + * seeds deterministically. Keeping resolution here lets the form initialize synchronously from a single source — and + * lets a resume skip the template fetch. */ export const ConfigureDeployment: FC = ({ dependencies: d = DEPENDENCIES }) => { const searchParams = d.useSearchParams(); @@ -58,37 +61,49 @@ export const ConfigureDeployment: FC = ({ dependencies: d = DEPENDENCIES const resolvedIntent = useMemo( () => ({ templateId: intent.templateId, + userTemplateId: intent.userTemplateId, sdlStrategy: intent.sdlStrategy, bidStrategy: intent.bidStrategy, dseq: intent.dseq, draftId: draft.draftId, vm: intent.vm }), - [intent.templateId, intent.sdlStrategy, intent.bidStrategy, intent.dseq, draft.draftId, intent.vm] + [intent.templateId, intent.userTemplateId, intent.sdlStrategy, intent.bidStrategy, intent.dseq, draft.draftId, intent.vm] ); const templateId = intent.templateId; const deploySdl = useAtomValue(sdlStore.deploySdl); const hardcodedTemplate: TemplateCreation | undefined = templateId ? hardcodedTemplates.find(template => template.code === templateId) : undefined; - const fetchedTemplateId = draft.persistedSdl === undefined && !hardcodedTemplate ? templateId : undefined; + const isDraftRestored = draft.persistedSdl !== undefined; + const fetchedTemplateId = isDraftRestored || hardcodedTemplate ? undefined : templateId; + const fetchedUserTemplateId = isDraftRestored ? undefined : intent.userTemplateId; const templateQuery = d.usePublicTemplate(fetchedTemplateId); + const userTemplateQuery = d.useUserTemplate(fetchedUserTemplateId); const { enqueueSnackbar } = d.useSnackbar(); + const isFetchingTemplate = !!fetchedTemplateId || !!fetchedUserTemplateId; + const isTemplateLoading = (!!fetchedTemplateId && templateQuery.isLoading) || (!!fetchedUserTemplateId && userTemplateQuery.isLoading); + const hasTemplateFailed = + (!!fetchedTemplateId && templateQuery.isError) || + (!!fetchedUserTemplateId && (userTemplateQuery.isError || (userTemplateQuery.isSuccess && !userTemplateQuery.data?.sdl))); + useEffect( function notifyOnTemplateError() { - if (!fetchedTemplateId || !templateQuery.isError) { + if (!hasTemplateFailed) { return; } enqueueSnackbar(, { variant: "error" }); }, - [fetchedTemplateId, templateQuery.isError, enqueueSnackbar, d] + [hasTemplateFailed, enqueueSnackbar, d] ); + const fetchedSdl = fetchedTemplateId ? templateQuery.data?.deploy : userTemplateQuery.data?.sdl; + const fetchedName = fetchedTemplateId ? templateQuery.data?.name : userTemplateQuery.data?.title; const carriedInSdl = intent.vm ? undefined : deploySdl?.content; - const initialSdl = draft.persistedSdl ?? hardcodedTemplate?.content ?? (fetchedTemplateId ? templateQuery.data?.deploy : carriedInSdl); - const initialName = draft.persistedName ?? hardcodedTemplate?.name ?? (fetchedTemplateId ? templateQuery.data?.name : undefined); + const initialSdl = draft.persistedSdl ?? hardcodedTemplate?.content ?? (isFetchingTemplate ? fetchedSdl : carriedInSdl); + const initialName = draft.persistedName ?? hardcodedTemplate?.name ?? (isFetchingTemplate ? fetchedName : undefined); const isAutoDeploy = resolvedIntent.sdlStrategy === "default" && resolvedIntent.bidStrategy === "auto"; const templateName = templateQuery.data?.name ?? hardcodedTemplate?.title ?? "your deployment"; @@ -101,7 +116,7 @@ export const ConfigureDeployment: FC = ({ dependencies: d = DEPENDENCIES return ( {resume => { - if (fetchedTemplateId && templateQuery.isLoading) { + if (isTemplateLoading) { return ( diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/deploymentIntent.spec.ts b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/deploymentIntent.spec.ts index 6a6b6f814a..4dbd3a90d9 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/deploymentIntent.spec.ts +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/deploymentIntent.spec.ts @@ -72,4 +72,28 @@ describe(parseDeploymentIntent.name, () => { expect(intent.sdlStrategy).toBe("edit"); expect(intent.vm).toBe(true); }); + + it("reads a user template id", () => { + const intent = parseDeploymentIntent({ dseqSegment: undefined, searchParams: new URLSearchParams("userTemplateId=user-1") }); + expect(intent.userTemplateId).toBe("user-1"); + expect(intent.templateId).toBeUndefined(); + expect(intent.sdlStrategy).toBe("edit"); + }); + + it("treats an empty user template id as missing", () => { + const intent = parseDeploymentIntent({ dseqSegment: undefined, searchParams: new URLSearchParams("userTemplateId=") }); + expect(intent.userTemplateId).toBeUndefined(); + }); + + it("drops the user template id on a vm entry", () => { + const intent = parseDeploymentIntent({ dseqSegment: undefined, searchParams: new URLSearchParams("vm=true&userTemplateId=user-1") }); + expect(intent.userTemplateId).toBeUndefined(); + expect(intent.vm).toBe(true); + }); + + it("prefers a gallery template id over a user template id", () => { + const intent = parseDeploymentIntent({ dseqSegment: undefined, searchParams: new URLSearchParams("templateId=abc&userTemplateId=user-1") }); + expect(intent.templateId).toBe("abc"); + expect(intent.userTemplateId).toBeUndefined(); + }); }); diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/deploymentIntent.ts b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/deploymentIntent.ts index 8b5f30e206..deecb7344c 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/deploymentIntent.ts +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/deploymentIntent.ts @@ -3,6 +3,8 @@ export type BidStrategy = "auto" | "select"; export interface DeploymentIntent { templateId?: string; + /** A user-authored template (`?userTemplateId=`), resolved against the private user-template API rather than the public gallery. */ + userTemplateId?: string; sdlStrategy: SdlStrategy; bidStrategy: BidStrategy; dseq?: string; @@ -23,15 +25,19 @@ interface ParseInput { * safe manual defaults (`edit`/`select`) on anything unrecognized, and `sdl-strategy` is only * honored alongside a `templateId` (it governs creating *from a template*). A `vm=true` entry * is a Container-VM seed, not a template build: it drops any `templateId` (and with it the - * `default` strategy), so the manual form always opens. + * `default` strategy), so the manual form always opens. The two template sources are mutually + * exclusive and resolved here rather than downstream: a `vm` entry drops both, and a gallery + * `templateId` wins over a `userTemplateId`. A user template never carries a `sdl-strategy`, so + * it always opens the manual form rather than the auto-deploy flow. */ export function parseDeploymentIntent({ dseqSegment, searchParams }: ParseInput): DeploymentIntent { const vm = searchParams.get("vm") === "true"; const templateId = vm ? undefined : searchParams.get("templateId") ?? undefined; + const userTemplateId = vm || templateId ? undefined : searchParams.get("userTemplateId") || undefined; const sdlStrategy = templateId ? toSdlStrategy(searchParams.get("sdl-strategy")) : "edit"; const bidStrategy = toBidStrategy(searchParams.get("bid-strategy")); const draftId = searchParams.get("draftId") || undefined; - return { templateId, sdlStrategy, bidStrategy, dseq: dseqSegment || undefined, draftId, vm }; + return { templateId, userTemplateId, sdlStrategy, bidStrategy, dseq: dseqSegment || undefined, draftId, vm }; } /** Narrows the raw `sdl-strategy` param to the union, defaulting to `edit`. */ diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.spec.tsx index 111ce7e603..f61377d654 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.spec.tsx @@ -899,4 +899,18 @@ describe(buildConfigureUrl.name, () => { expect(url).not.toContain("vm="); }); + + it("preserves the user template id across rewrites", () => { + const url = buildConfigureUrl({ sdlStrategy: "edit", bidStrategy: "select", userTemplateId: "user-1", draftId: "draft-1", vm: false }, "999", "select"); + + expect(url).toContain("/new-deployment/configure/999"); + expect(url).toContain("userTemplateId=user-1"); + expect(url).toContain("draftId=draft-1"); + }); + + it("omits the user template id when the intent carries none", () => { + const url = buildConfigureUrl({ sdlStrategy: "edit", bidStrategy: "select", vm: false }, undefined, "select"); + + expect(url).not.toContain("userTemplateId"); + }); }); diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.ts b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.ts index d3cda5f37b..4e5c914cab 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.ts +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.ts @@ -490,11 +490,12 @@ function deploymentResourcesFromSdl(sdl: string): { gpuAmount: number; cpuAmount } } -/** Builds the canonical configure URL preserving templateId/sdl-strategy/draftId/vm and the current dseq + bid-strategy. */ +/** Builds the canonical configure URL preserving both template ids, sdl-strategy, draftId and vm alongside the current dseq + bid-strategy. */ export function buildConfigureUrl(intent: DeploymentIntent, dseq: string | undefined, bidStrategy: BidStrategy): string { return UrlService.configureDeployment({ dseq, templateId: intent.templateId, + userTemplateId: intent.userTemplateId, sdlStrategy: intent.templateId ? intent.sdlStrategy : undefined, bidStrategy, draftId: intent.draftId, diff --git a/apps/deploy-web/src/components/new-deployment/RedirectDeployLinuxToConfigure/RedirectDeployLinuxToConfigure.spec.tsx b/apps/deploy-web/src/components/new-deployment/RedirectDeployLinuxToConfigure/RedirectDeployLinuxToConfigure.spec.tsx deleted file mode 100644 index 47fff264d2..0000000000 --- a/apps/deploy-web/src/components/new-deployment/RedirectDeployLinuxToConfigure/RedirectDeployLinuxToConfigure.spec.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { mock } from "vitest-mock-extended"; - -import { DEPENDENCIES, RedirectDeployLinuxToConfigure } from "./RedirectDeployLinuxToConfigure"; - -import { render } from "@testing-library/react"; - -describe(RedirectDeployLinuxToConfigure.name, () => { - it("redirects to the configure vm seed", () => { - const { replace } = setup(); - expect(replace).toHaveBeenCalledWith("/new-deployment/configure?vm=true"); - }); - - function setup() { - const replace = vi.fn(); - const d: typeof DEPENDENCIES = { - ...DEPENDENCIES, - useRouter: (() => mock>({ replace })) as typeof DEPENDENCIES.useRouter - }; - render(); - return { replace }; - } -}); diff --git a/apps/deploy-web/src/components/new-deployment/RedirectDeployLinuxToConfigure/RedirectDeployLinuxToConfigure.tsx b/apps/deploy-web/src/components/new-deployment/RedirectDeployLinuxToConfigure/RedirectDeployLinuxToConfigure.tsx deleted file mode 100644 index 176c53f49b..0000000000 --- a/apps/deploy-web/src/components/new-deployment/RedirectDeployLinuxToConfigure/RedirectDeployLinuxToConfigure.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/router"; - -import { BootLoading } from "@src/context/BootLoadingProvider/BootLoadingProvider"; -import { UrlService } from "@src/utils/urlUtils"; - -export const DEPENDENCIES = { useRouter, UrlService }; - -type Props = { dependencies?: typeof DEPENDENCIES }; - -/** - * The Container-VM experience lives on the configure screen as a `vm=true` seed, so every visit to the classic - * `/deploy-linux` route is replaced there. Intentionally client-only, matching - * {@link RedirectMappableBuilderToConfigure} (the team is phasing getServerSideProps out). - */ -export function RedirectDeployLinuxToConfigure({ dependencies: d = DEPENDENCIES }: Props = {}) { - const router = d.useRouter(); - - useEffect( - function redirectDeployLinuxToConfigure() { - router.replace(d.UrlService.configureDeployment({ vm: true })); - }, - [router, d.UrlService] - ); - - return ; -} diff --git a/apps/deploy-web/src/components/new-deployment/TemplateList.spec.tsx b/apps/deploy-web/src/components/new-deployment/TemplateList.spec.tsx index ed77117848..514629048b 100644 --- a/apps/deploy-web/src/components/new-deployment/TemplateList.spec.tsx +++ b/apps/deploy-web/src/components/new-deployment/TemplateList.spec.tsx @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import type { AnalyticsService } from "@src/services/analytics/analytics.service"; +import { RouteStep } from "@src/types/route-steps.type"; import { UrlService } from "@src/utils/urlUtils"; import type { DEPENDENCIES } from "./TemplateList"; import { TemplateList } from "./TemplateList"; @@ -58,6 +59,26 @@ describe(TemplateList.name, () => { expect(push).not.toHaveBeenCalled(); }); + it("routes Launch Container-VM straight to a container-vm configure entry", async () => { + const { push, analyticsService, onTemplateSelected, setEditedManifest } = setup({}); + + await userEvent.click(screen.getByText("Launch Container-VM")); + + expect(push).toHaveBeenCalledWith(UrlService.configureDeployment({ vm: true })); + expect(analyticsService.track).toHaveBeenCalledWith("launch_container_vm_btn_clk", "Amplitude"); + expect(setEditedManifest).toHaveBeenCalledWith(""); + expect(onTemplateSelected).toHaveBeenCalledWith(null); + }); + + it("routes Run Custom Container to the blank deployment editor", async () => { + const { push, analyticsService } = setup({}); + + await userEvent.click(screen.getByText("Run Custom Container")); + + expect(push).toHaveBeenCalledWith(UrlService.newDeployment({ step: RouteStep.editDeployment })); + expect(analyticsService.track).toHaveBeenCalledWith("run_custom_container_btn_clk", "Amplitude"); + }); + /** A YAML File the mocked FileButton hands to the upload handler, standing in for the browser's file picker. */ function sdlFile(content: string) { return new File([content], "deploy.yaml", { type: "application/x-yaml" }); diff --git a/apps/deploy-web/src/components/new-deployment/TemplateList.tsx b/apps/deploy-web/src/components/new-deployment/TemplateList.tsx index bb4e56e67c..2c5f87166e 100644 --- a/apps/deploy-web/src/components/new-deployment/TemplateList.tsx +++ b/apps/deploy-web/src/components/new-deployment/TemplateList.tsx @@ -20,7 +20,6 @@ import type { TemplateCreation } from "@src/types"; import { RouteStep } from "@src/types/route-steps.type"; import { importSimpleSdl } from "@src/utils/sdl/sdlImport"; import { helloWorldTemplate } from "@src/utils/templates"; -import type { NewDeploymentParams } from "@src/utils/urlUtils"; import { domainName, UrlService } from "@src/utils/urlUtils"; import { CustomNextSeo } from "../shared/CustomNextSeo"; import { TemplateBox } from "../templates/TemplateBox"; @@ -86,12 +85,22 @@ export const TemplateList: React.FunctionComponent = ({ } }, [templates]); - function onSDLBuilderClick(page: NewDeploymentParams["page"] = "new-deployment") { - analyticsService.track(page === "deploy-linux" ? "launch_container_vm_btn_clk" : "run_custom_container_btn_clk", "Amplitude"); + function startBlankDeployment() { setEditedManifest(""); onTemplateSelected(null); setSdlEditMode("builder"); - router.push(newDeploymentUrl({ step: RouteStep.editDeployment, page })); + } + + function onRunCustomContainerClick() { + analyticsService.track("run_custom_container_btn_clk", "Amplitude"); + startBlankDeployment(); + router.push(newDeploymentUrl({ step: RouteStep.editDeployment })); + } + + function onLaunchContainerVmClick() { + analyticsService.track("launch_container_vm_btn_clk", "Amplitude"); + startBlankDeployment(); + router.push(UrlService.configureDeployment({ vm: true })); } const onFileSelect = (file: File | null) => { @@ -154,14 +163,14 @@ export const TemplateList: React.FunctionComponent = ({ description="Deploy and work with a plain-linux vm-like container" topIcons={["/images/docker-logo.png", "/images/vm.png"]} bottomIcons={["/images/ubuntu.png", "/images/centos.png", "/images/debian.png", "/images/suse.png"]} - onClick={() => onSDLBuilderClick("deploy-linux")} + onClick={onLaunchContainerVmClick} /> onSDLBuilderClick()} + onClick={onRunCustomContainerClick} /> diff --git a/apps/deploy-web/src/components/sdl/ImportSdlModal.tsx b/apps/deploy-web/src/components/sdl/ImportSdlModal.tsx deleted file mode 100644 index d2e12e98df..0000000000 --- a/apps/deploy-web/src/components/sdl/ImportSdlModal.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; -import type { ReactNode } from "react"; -import { useCallback, useState } from "react"; -import type { UseFormSetValue } from "react-hook-form"; -import { Alert, Popup, Snackbar } from "@akashnetwork/ui/components"; -import { ArrowDown } from "iconoir-react"; -import type { editor } from "monaco-editor"; -import { useTheme } from "next-themes"; -import { useSnackbar } from "notistack"; - -import { useServices } from "@src/context/ServicesProvider"; -import type { SdlBuilderFormValuesType } from "@src/types"; -import { importSimpleSdl } from "@src/utils/sdl/sdlImport"; -import { SDLEditor } from "./SDLEditor/SDLEditor"; - -type Props = { - setValue: UseFormSetValue; - onClose: () => void; - children?: ReactNode; -}; - -export const ImportSdlModal: React.FunctionComponent = ({ onClose, setValue }) => { - const { analyticsService } = useServices(); - const [sdl, setSdl] = useState(""); - const [parsingError, setParsingError] = useState(null); - const { enqueueSnackbar } = useSnackbar(); - const { resolvedTheme } = useTheme(); - const onEditorMount = useCallback((editorInstance: editor.IStandaloneCodeEditor) => { - editorInstance.focus(); - }, []); - - const createAndValidateSdl = (yamlStr: string) => { - try { - if (!yamlStr) return null; - - const formValues = importSimpleSdl(yamlStr, { placementPerService: true }); - - setParsingError(null); - - return formValues; - } catch (err: any) { - if (err.name === "YAMLException" || err.name === "CustomValidationError") { - setParsingError(err.message); - } else if (err.name === "TemplateValidation") { - setParsingError(err.message); - } else { - setParsingError("Error while parsing SDL file"); - console.error(err); - } - } - }; - - const onImport = () => { - const result = createAndValidateSdl(sdl || ""); - - if (!result) return; - - setValue("placements", result.placements); - setValue("services", result.services); - - enqueueSnackbar(, { - variant: "success", - autoHideDuration: 4000 - }); - - analyticsService.track("import_sdl", { - category: "sdl_builder", - label: "Import SDL" - }); - - onClose(); - }; - - return ( - -
- Paste your sdl here to import -
-
- setSdl(value)} - theme={resolvedTheme === "dark" ? "vs-dark" : "light"} - onMount={onEditorMount} - onValidate={() => setParsingError(null)} - /> -
- {parsingError && ( - - {parsingError} - - )} -
- ); -}; diff --git a/apps/deploy-web/src/components/sdl/PreviewSdl.tsx b/apps/deploy-web/src/components/sdl/PreviewSdl.tsx deleted file mode 100644 index 926234f978..0000000000 --- a/apps/deploy-web/src/components/sdl/PreviewSdl.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; -import type { ReactNode } from "react"; -import { Button, Popup, Snackbar } from "@akashnetwork/ui/components"; -import { Copy } from "iconoir-react"; -import { useTheme } from "next-themes"; -import { useSnackbar } from "notistack"; - -import { copyTextToClipboard } from "@src/utils/copyClipboard"; -import { SDLEditor } from "./SDLEditor/SDLEditor"; - -type Props = { - sdl: string; - onClose: () => void; - children?: ReactNode; -}; - -export const PreviewSdl: React.FunctionComponent = ({ sdl, onClose }) => { - const { resolvedTheme } = useTheme(); - const { enqueueSnackbar } = useSnackbar(); - - const onCopyClick = () => { - copyTextToClipboard(sdl); - enqueueSnackbar(, { - variant: "success", - autoHideDuration: 3000 - }); - }; - - return ( - -
- -
-
- -
-
- ); -}; diff --git a/apps/deploy-web/src/components/sdl/SaveTemplateModal.tsx b/apps/deploy-web/src/components/sdl/SaveTemplateModal.tsx deleted file mode 100644 index d2231bd5f1..0000000000 --- a/apps/deploy-web/src/components/sdl/SaveTemplateModal.tsx +++ /dev/null @@ -1,219 +0,0 @@ -"use client"; -import type { ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { Alert, Form, FormField, FormInput, Label, Popup, RadioGroup, RadioGroupItem, Snackbar } from "@akashnetwork/ui/components"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useSetAtom } from "jotai"; -import { useRouter } from "next/router"; -import { useSnackbar } from "notistack"; -import { z } from "zod"; - -import { MustConnect } from "@src/components/shared/MustConnect"; -import { useServices } from "@src/context/ServicesProvider"; -import { useCustomUser } from "@src/hooks/useCustomUser"; -import { getShortText } from "@src/hooks/useShortText"; -import { useSaveUserTemplate } from "@src/queries/useTemplateQuery"; -import sdlStore from "@src/store/sdlStore"; -import type { EnvironmentVariableType, ITemplate, ServiceType } from "@src/types"; -import { UrlService } from "@src/utils/urlUtils"; - -type Props = { - services: ServiceType[]; - templateMetadata: ITemplate; - getTemplateData: () => Partial; - setTemplateMetadata: (value: ITemplate) => void; - onClose: () => void; - clearFormStorage: () => void; - children?: ReactNode; -}; - -const formSchema = z.object({ - title: z.string().min(3, "Title must be at least 3 characters long"), - visibility: z.enum(["private", "public"]) -}); -type FormValues = z.infer; - -export const SaveTemplateModal: React.FunctionComponent = ({ - onClose, - getTemplateData, - templateMetadata, - setTemplateMetadata, - services, - clearFormStorage -}) => { - const { analyticsService } = useServices(); - const [publicEnvs, setPublicEnvs] = useState([]); - const { enqueueSnackbar } = useSnackbar(); - const formRef = useRef(null); - const { user, isLoading: isLoadingUser } = useCustomUser(); - const isRestricted = !isLoadingUser && !user; - const isCurrentUserTemplate = !isRestricted && user?.sub === templateMetadata?.userId; - const router = useRouter(); - const setSdlBuilderSdl = useSetAtom(sdlStore.sdlBuilderSdl); - const { mutate: saveTemplate, isPending: isSaving } = useSaveUserTemplate(); - const form = useForm({ - defaultValues: { - title: "", - visibility: "private" - }, - resolver: zodResolver(formSchema) - }); - const { handleSubmit, control, setValue } = form; - - useEffect(() => { - const envs = services.some(s => s.env?.some(e => !e.isSecret)) - ? services.reduce((cur: EnvironmentVariableType[], prev) => cur.concat([...(prev.env?.filter(e => !e.isSecret) as EnvironmentVariableType[])]), []) - : []; - setPublicEnvs(envs); - - if (templateMetadata && isCurrentUserTemplate) { - setValue("title", templateMetadata.title); - setValue("visibility", templateMetadata.isPublic ? "public" : "private"); - } - }, []); - - const onSubmit = async (data: FormValues) => { - const template = getTemplateData(); - const isUpdating = !!templateMetadata?.id; - - saveTemplate( - { ...template, title: data.title, isPublic: data.visibility !== "private" }, - { - onSuccess: response => { - const responseData = response?.data; - const newId = typeof responseData === "string" ? responseData : responseData?.id; - - const newTemplateMetadata = { - ...templateMetadata, - id: newId, - title: data.title, - isPublic: data.visibility !== "private" - }; - - if (!isCurrentUserTemplate) { - newTemplateMetadata.username = user?.username || ""; - newTemplateMetadata.userId = user?.sub || ""; - } - - setTemplateMetadata(newTemplateMetadata); - - enqueueSnackbar(, { - variant: "success" - }); - - if (isUpdating) { - analyticsService.track("update_sdl_template", { - category: "sdl_builder", - label: "Update SDL template" - }); - } else { - analyticsService.track("create_sdl_template", { - category: "sdl_builder", - label: "Create SDL template" - }); - } - - // Clear the SDL builder storage so the saved template becomes the source of truth - setSdlBuilderSdl(null); - clearFormStorage(); - - onClose(); - - // Navigate to the new template URL after metadata is set - if (!isCurrentUserTemplate && newId) { - router.push(UrlService.sdlBuilder(newId)); - } - } - } - ); - }; - - const onSave = () => { - formRef.current?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true })); - }; - - return ( - -
- {isRestricted ? ( - - ) : ( -
- - ( - field.onChange(event.target.value)} - /> - )} - /> - - ( - -
- - -
-
- - -
-
- )} - /> - - {publicEnvs.length > 0 && ( - - You have {publicEnvs.length} public environment variables. Are you sure you don't need to hide them as secret? -
    - {publicEnvs.map((e, i) => ( -
  • - {e.key}={getShortText(e.value, 30)} -
  • - ))} -
-
- )} - - - )} -
-
- ); -}; diff --git a/apps/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx b/apps/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx deleted file mode 100644 index c95a5e271b..0000000000 --- a/apps/deploy-web/src/components/sdl/SimpleSdlBuilderForm.tsx +++ /dev/null @@ -1,325 +0,0 @@ -"use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { Alert, Button, Form, Snackbar, Spinner } from "@akashnetwork/ui/components"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { NavArrowRight } from "iconoir-react"; -import { useAtom } from "jotai"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useSnackbar } from "notistack"; - -import { SimpleServiceFormControl } from "@src/components/sdl/SimpleServiceFormControl"; -import { USER_TEMPLATE_CODE } from "@src/config/deploy.config"; -import { useServices } from "@src/context/ServicesProvider"; -import useFormPersist from "@src/hooks/useFormPersist"; -import { useSdlServiceManager } from "@src/hooks/useSdlServiceManager/useSdlServiceManager"; -import { useGpuModels } from "@src/queries/useGpuQuery"; -import sdlStore from "@src/store/sdlStore"; -import type { ITemplate, SdlBuilderFormValuesType, ServiceType } from "@src/types"; -import { SdlBuilderFormValuesSchema } from "@src/types"; -import { RouteStep } from "@src/types/route-steps.type"; -import { memoryUnits, storageUnits } from "@src/utils/akash/units"; -import { defaultServiceWithPlacement, healSdlBuilderDraft } from "@src/utils/sdl/data"; -import { generateSdl } from "@src/utils/sdl/sdlGenerator"; -import { importSimpleSdl } from "@src/utils/sdl/sdlImport"; -import { UrlService } from "@src/utils/urlUtils"; -import { ImportSdlModal } from "./ImportSdlModal"; -import { PreviewSdl } from "./PreviewSdl"; -import { SaveTemplateModal } from "./SaveTemplateModal"; - -export const SimpleSDLBuilderForm: React.FunctionComponent = () => { - const { consoleApiHttpClient, analyticsService } = useServices(); - const [error, setError] = useState(null); - const [templateMetadata, setTemplateMetadata] = useState(null); - const [serviceCollapsed, setServiceCollapsed] = useState([]); - const [isLoadingTemplate, setIsLoadingTemplate] = useState(false); - const [isSavingTemplate, setIsSavingTemplate] = useState(false); - const [isImportingSdl, setIsImportingSdl] = useState(false); - const [isPreviewingSdl, setIsPreviewingSdl] = useState(false); - const [sdlResult, setSdlResult] = useState(null); - const formRef = useRef(null); - const [, setDeploySdl] = useAtom(sdlStore.deploySdl); - const [sdlBuilderSdl, setSdlBuilderSdl] = useAtom(sdlStore.sdlBuilderSdl); - const { data: gpuModels } = useGpuModels(); - const { enqueueSnackbar } = useSnackbar(); - const initialValues = useMemo(() => defaultServiceWithPlacement(), []); - const form = useForm({ - resolver: zodResolver(SdlBuilderFormValuesSchema), - defaultValues: initialValues - }); - const { handleSubmit, reset, control, trigger, watch, setValue } = form; - const { clear: clearFormStorage } = useFormPersist("sdl-builder-form", { - watch, - setValue, - defaultValues: initialValues, - storage: typeof window === "undefined" ? undefined : window.localStorage, - transform: healSdlBuilderDraft - }); - const { services: _services } = watch(); - const serviceManager = useSdlServiceManager({ control }); - const router = useRouter(); - const searchParams = useSearchParams(); - const templateQueryId = searchParams?.get("id"); - - useEffect(() => { - if (sdlBuilderSdl && sdlBuilderSdl.services) { - if (sdlBuilderSdl.placements) { - setValue("placements", sdlBuilderSdl.placements); - } - setValue("services", sdlBuilderSdl.services); - } - }, []); - - // Load the template from query string on mount - useEffect(() => { - if ((templateQueryId && !templateMetadata) || (templateQueryId && templateMetadata?.id !== templateQueryId)) { - // Load user template - loadTemplate(templateQueryId as string); - } else if (!templateQueryId && templateMetadata) { - // Navigating back to plain SDL builder - clear storage and reset to defaults - setTemplateMetadata(null); - setSdlBuilderSdl(null); - clearFormStorage(); - setServiceCollapsed([]); - reset(); - } - }, [templateQueryId, templateMetadata, clearFormStorage, setSdlBuilderSdl, reset]); - - const { placements: _placements } = watch(); - - useEffect(() => { - if (_services) { - setSdlBuilderSdl({ placements: _placements as SdlBuilderFormValuesType["placements"], services: _services as ServiceType[], endpoints: [] }); - } - }, [_services, _placements]); - - const loadTemplate = async (id: string) => { - try { - setIsLoadingTemplate(true); - const response = await consoleApiHttpClient.get(`/v1/user/template/${id}`); - const template: ITemplate = response.data; - - const imported = importSimpleSdl(template.sdl, { placementPerService: true }); - - setIsLoadingTemplate(false); - - reset(); - setValue("placements", imported.placements); - setValue("services", imported.services); - setServiceCollapsed(imported.services.map((x, i) => i)); - setTemplateMetadata(template); - } catch { - enqueueSnackbar(, { - variant: "error" - }); - - setIsLoadingTemplate(false); - } - }; - - const onSubmit = async (data: SdlBuilderFormValuesType) => { - setError(null); - - try { - const sdl = generateSdl(data); - - setDeploySdl({ - title: "", - category: "", - code: USER_TEMPLATE_CODE, - description: "", - content: sdl - }); - - router.push(UrlService.newDeployment({ step: RouteStep.editDeployment })); - - analyticsService.track("deploy_sdl", { - category: "sdl_builder", - label: "Deploy SDL from create page" - }); - } catch (error: any) { - setError(error.message); - } - }; - - const onSaveClick = async () => { - const result = await trigger(); - - if (result) { - setIsSavingTemplate(true); - } - }; - - const onPreviewSdlClick = () => { - setError(null); - - try { - const sdl = generateSdl(form.getValues()); - setSdlResult(sdl); - setIsPreviewingSdl(true); - - analyticsService.track("preview_sdl", { - category: "sdl_builder", - label: "Preview SDL from create page" - }); - } catch (error: any) { - setError(error.message); - } - }; - - const getTemplateData = () => { - const sdl = generateSdl(form.getValues()); - const template: Partial = { - id: templateMetadata?.id || undefined, - sdl, - cpu: _services?.map(s => (s.profile?.cpu || 0) * 1000).reduce((a, b) => a + b, 0), - ram: _services - ?.map(s => { - const ramUnit = memoryUnits.find(x => x.suffix === s.profile?.ramUnit); - - return (s.profile?.ram || 0) * (ramUnit?.value || 0); - }) - .reduce((a, b) => a + b, 0), - storage: _services - ?.map(s => { - return s.profile?.storage.reduce((memo, storage) => { - const storageUnit = storageUnits.find(x => x.suffix === storage.unit); - return memo + (storage.size || 0) * (storageUnit?.value || 0); - }, 0); - }) - .reduce((a, b) => a + b, 0) - }; - return template; - }; - - return ( - <> - {isImportingSdl && setIsImportingSdl(false)} setValue={setValue} />} - {isPreviewingSdl && setIsPreviewingSdl(false)} sdl={sdlResult || ""} />} - {isSavingTemplate && ( - setIsSavingTemplate(false)} - getTemplateData={getTemplateData} - templateMetadata={templateMetadata as ITemplate} - setTemplateMetadata={setTemplateMetadata} - services={_services as ServiceType[]} - clearFormStorage={clearFormStorage} - /> - )} - -
- - {templateMetadata && ( -
-

- {templateMetadata.title} by  - {templateMetadata.username && ( - { - analyticsService.track("click_sdl_profile", { - category: "sdl_builder", - label: "Click on SDL user profile" - }); - }} - > - {templateMetadata.username} - - )} -

- -
- { - analyticsService.track("click_view_template", { - category: "sdl_builder", - label: "Click on view SDL template" - }); - }} - > - View template - -
-
- )} - -
-
- - - - - - - - - {isLoadingTemplate && ( -
- -
- )} -
- -
- -
-
- - {_services?.map((service, serviceIndex) => ( - - ))} - - {error && ( - - {error} - - )} - -
-
- -
-
- - - - ); -}; diff --git a/apps/deploy-web/src/components/templates/UserTemplate.tsx b/apps/deploy-web/src/components/templates/UserTemplate.tsx index 13538489a4..09d3022563 100644 --- a/apps/deploy-web/src/components/templates/UserTemplate.tsx +++ b/apps/deploy-web/src/components/templates/UserTemplate.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import { Button, buttonVariants, Card, CardContent, Popup } from "@akashnetwork/ui/components"; import { cn } from "@akashnetwork/ui/utils"; import { Bin, Edit, Rocket } from "iconoir-react"; -import { useAtom } from "jotai"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -11,14 +10,11 @@ import { EditDescriptionForm } from "@src/components/sdl/EditDescriptionForm"; import { LeaseSpecDetail } from "@src/components/shared/LeaseSpecDetail"; import { Title } from "@src/components/shared/Title"; import { UserFavoriteButton } from "@src/components/shared/UserFavoriteButton"; -import { USER_TEMPLATE_CODE } from "@src/config/deploy.config"; import { useServices } from "@src/context/ServicesProvider"; import { useCustomUser } from "@src/hooks/useCustomUser"; import { getShortText } from "@src/hooks/useShortText"; import { useDeleteTemplate } from "@src/queries/useTemplateQuery"; -import sdlStore from "@src/store/sdlStore"; import type { ITemplate } from "@src/types"; -import { RouteStep } from "@src/types/route-steps.type"; import { roundDecimal } from "@src/utils/mathHelpers"; import { bytesToShrink } from "@src/utils/unitUtils"; import { domainName, UrlService } from "@src/utils/urlUtils"; @@ -41,7 +37,6 @@ export const UserTemplate: React.FunctionComponent = ({ id, template }) = const _ram = bytesToShrink(template.ram); const _storage = bytesToShrink(template.storage); const router = useRouter(); - const [, setDeploySdl] = useAtom(sdlStore.deploySdl); useEffect(() => { const desc = template.description || ""; @@ -129,15 +124,7 @@ export const UserTemplate: React.FunctionComponent = ({ id, template }) = label: "Deploy SDL from template detail" }); - setDeploySdl({ - title: "", - category: "", - code: USER_TEMPLATE_CODE, - description: "", - content: template.sdl - }); - - router.push(UrlService.newDeployment({ step: RouteStep.editDeployment })); + router.push(UrlService.configureDeployment({ userTemplateId: template.id })); }} size="sm" className="space-x-2" @@ -147,7 +134,7 @@ export const UserTemplate: React.FunctionComponent = ({ id, template }) = { analyticsService.track("click_edit_sdl_template", { diff --git a/apps/deploy-web/src/components/user/UserProfile.tsx b/apps/deploy-web/src/components/user/UserProfile.tsx index 2a660fe83a..7256789899 100644 --- a/apps/deploy-web/src/components/user/UserProfile.tsx +++ b/apps/deploy-web/src/components/user/UserProfile.tsx @@ -38,7 +38,7 @@ export const UserProfile: React.FunctionComponent = ({ username, user }) {username === _user?.username && ( { analyticsService.track("create_sdl_template_link", { category: "profile", diff --git a/apps/deploy-web/src/hooks/useFormPersist.spec.ts b/apps/deploy-web/src/hooks/useFormPersist.spec.ts deleted file mode 100644 index e869ab0c2b..0000000000 --- a/apps/deploy-web/src/hooks/useFormPersist.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { UACT_DENOM } from "@src/config/denom.config"; -import { defaultPricing, healSdlBuilderDraft } from "@src/utils/sdl/data"; -import useFormPersist from "./useFormPersist"; - -import { renderHook } from "@testing-library/react"; - -describe(useFormPersist.name, () => { - it("applies transform to restored values before setting them on the form", () => { - const transform = vi.fn((values: Record) => ({ ...values, services: "transformed" })); - const { setValue } = setup({ - stored: JSON.stringify({ _timestamp: 123, services: "stale" }), - transform - }); - - expect(transform).toHaveBeenCalledWith({ services: "stale" }); - expect(setValue).toHaveBeenCalledWith("services", "transformed", expect.any(Object)); - }); - - it("restores values unchanged when no transform is given", () => { - const { setValue } = setup({ stored: JSON.stringify({ services: "stale" }) }); - - expect(setValue).toHaveBeenCalledWith("services", "stale", expect.any(Object)); - }); - - it("recovers from corrupted storage by clearing it and applying the default values", () => { - const { setValue } = setup({ - stored: "{not-json", - defaultValues: { services: "defaults" } - }); - - expect(window.localStorage.getItem(STORAGE_KEY)).not.toBe("{not-json"); - expect(setValue).toHaveBeenCalledWith("services", "defaults", expect.any(Object)); - }); - - it("heals a pre-uact draft missing service pricing when given the sdl-builder transform", () => { - const preUactDraft = { - placements: [{ id: "p1", name: "dcloud" }], - services: [{ id: "s1", title: "web", image: "nginx", profile: { cpu: 0.1 }, expose: [], placementId: "p1" }], - endpoints: [] - }; - const { setValue } = setup({ - stored: JSON.stringify(preUactDraft), - transform: healSdlBuilderDraft - }); - - expect(setValue).toHaveBeenCalledWith( - "services", - [expect.objectContaining({ image: "nginx", pricing: { amount: defaultPricing().amount, denom: UACT_DENOM } })], - expect.any(Object) - ); - }); - - const STORAGE_KEY = "test-form"; - - function setup(input?: { stored?: string; transform?: (values: Record) => Record; defaultValues?: Record }) { - window.localStorage.clear(); - - if (input?.stored !== undefined) { - window.localStorage.setItem(STORAGE_KEY, input.stored); - } - - const watch = vi.fn(); - const setValue = vi.fn(); - - const rendered = renderHook(() => - useFormPersist(STORAGE_KEY, { - watch, - setValue, - storage: window.localStorage, - defaultValues: input?.defaultValues, - transform: input?.transform - }) - ); - - return { watch, setValue, rendered }; - } -}); diff --git a/apps/deploy-web/src/hooks/useFormPersist.tsx b/apps/deploy-web/src/hooks/useFormPersist.tsx deleted file mode 100644 index 529607b050..0000000000 --- a/apps/deploy-web/src/hooks/useFormPersist.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useEffect } from "react"; -import type { SetFieldValue } from "react-hook-form"; - -export interface FormPersistConfig { - storage?: Storage; - watch: (names?: string | string[]) => any; - setValue: SetFieldValue; - exclude?: string[]; - onDataRestored?: (data: any) => void; - validate?: boolean; - dirty?: boolean; - touch?: boolean; - onTimeout?: () => void; - timeout?: number; - defaultValues?: any; - /** - * Maps restored values before they are applied to the form — e.g. to migrate - * drafts persisted with an older shape. Must be an idempotent, stable reference. - */ - transform?: (values: Record) => Record; -} - -const useFormPersist = ( - name: string, - { - storage, - watch, - setValue, - exclude = [], - onDataRestored, - validate = false, - dirty = false, - touch = false, - onTimeout, - timeout, - defaultValues, - transform - }: FormPersistConfig -) => { - const watchedValues = watch(); - - const getStorage = () => storage || window.sessionStorage; - - const clearStorage = () => getStorage().removeItem(name); - - useEffect(() => { - const str = getStorage().getItem(name); - let parsed = defaultValues; - - if (str) { - try { - parsed = JSON.parse(str); - } catch { - clearStorage(); - parsed = defaultValues; - } - } - - if (parsed) { - const { _timestamp = null, ...restored } = parsed; - const dataRestored: { [key: string]: any } = {}; - const currTimestamp = Date.now(); - - if (timeout && currTimestamp - _timestamp > timeout) { - if (onTimeout) onTimeout(); - clearStorage(); - return; - } - - const values = transform ? transform(restored) : restored; - - Object.keys(values).forEach(key => { - const shouldSet = !exclude.includes(key); - if (shouldSet) { - dataRestored[key] = values[key]; - setValue(key, values[key], { - shouldValidate: validate, - shouldDirty: dirty, - shouldTouch: touch - }); - } - }); - - if (onDataRestored) { - onDataRestored(dataRestored); - } - } - }, [storage, name, onDataRestored, setValue, defaultValues, transform]); - - useEffect(() => { - const values = exclude.length - ? Object.entries(watchedValues) - .filter(([key]) => !exclude.includes(key)) - .reduce((obj, [key, val]) => Object.assign(obj, { [key]: val }), {}) - : Object.assign({}, watchedValues); - - if (Object.entries(values).length) { - if (timeout !== undefined) { - values._timestamp = Date.now(); - } - getStorage().setItem(name, JSON.stringify(values)); - } - }, [watchedValues, timeout]); - - return { - clear: () => getStorage().removeItem(name) - }; -}; - -export default useFormPersist; diff --git a/apps/deploy-web/src/hooks/useNewDeploymentUrl/useNewDeploymentUrl.spec.ts b/apps/deploy-web/src/hooks/useNewDeploymentUrl/useNewDeploymentUrl.spec.ts index 6c2f14bc84..d1832da932 100644 --- a/apps/deploy-web/src/hooks/useNewDeploymentUrl/useNewDeploymentUrl.spec.ts +++ b/apps/deploy-web/src/hooks/useNewDeploymentUrl/useNewDeploymentUrl.spec.ts @@ -27,11 +27,6 @@ describe(useNewDeploymentUrl.name, () => { expect(make({ redeploy: "42" })).toContain("/new-deployment?"); }); - it("maps the container-vm intent to the configure vm seed", () => { - const make = build(); - expect(make({ step: RouteStep.editDeployment, page: "deploy-linux" })).toBe("/new-deployment/configure?vm=true"); - }); - function build() { return renderHook(() => useNewDeploymentUrl(DEPENDENCIES)).result.current; } diff --git a/apps/deploy-web/src/hooks/useNewDeploymentUrl/useNewDeploymentUrl.ts b/apps/deploy-web/src/hooks/useNewDeploymentUrl/useNewDeploymentUrl.ts index 0d331b0a95..df9749ad1a 100644 --- a/apps/deploy-web/src/hooks/useNewDeploymentUrl/useNewDeploymentUrl.ts +++ b/apps/deploy-web/src/hooks/useNewDeploymentUrl/useNewDeploymentUrl.ts @@ -5,11 +5,10 @@ export const DEPENDENCIES = { UrlService }; /** * Resolves the "open the deployment editor" destination. A concrete build intent — a chosen template or the - * `edit-deployment` step — opens the `/new-deployment/configure` screen, and a Container-VM intent - * (`deploy-linux`) opens it as a VM seed (`vm=true`). A bare "new deployment" keeps the classic - * `/new-deployment` deployment-type/template picker so it stays the reachable entry point (its own template - * picks then route on to configure); git and redeploy intents are not representable on configure and always - * keep the classic URL. + * `edit-deployment` step — opens the `/new-deployment/configure` screen. A bare "new deployment" keeps the + * classic `/new-deployment` deployment-type/template picker so it stays the reachable entry point (its own + * template picks then route on to configure); repository and redeploy intents are not representable on + * configure and always keep the classic URL. */ export function useNewDeploymentUrl(dependencies = DEPENDENCIES) { const d = dependencies; @@ -17,9 +16,6 @@ export function useNewDeploymentUrl(dependencies = DEPENDENCIES) { return function newDeploymentUrl(params: NewDeploymentParams = {}) { const isClassicOnly = !!params.redeploy || !!params.gitProvider || !!params.repoUrl; const opensBuilder = params.step === "edit-deployment" || !!params.templateId; - if (!isClassicOnly && params.page === "deploy-linux") { - return d.UrlService.configureDeployment({ vm: true }); - } if (!isClassicOnly && opensBuilder) { return d.UrlService.configureDeployment({ templateId: params.templateId }); } diff --git a/apps/deploy-web/src/hooks/useReturnTo/useReturnTo.ts b/apps/deploy-web/src/hooks/useReturnTo/useReturnTo.ts index b2b9286d45..86de8aecf7 100644 --- a/apps/deploy-web/src/hooks/useReturnTo/useReturnTo.ts +++ b/apps/deploy-web/src/hooks/useReturnTo/useReturnTo.ts @@ -114,7 +114,7 @@ export const useReturnTo = ; -} - -export default DeployLinuxPage; - -export const getServerSideProps = createServerSideProps("/deploy-linux"); diff --git a/apps/deploy-web/src/pages/sdl-builder/index.tsx b/apps/deploy-web/src/pages/sdl-builder/index.tsx deleted file mode 100644 index 57c83c8997..0000000000 --- a/apps/deploy-web/src/pages/sdl-builder/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; - -import Layout from "@src/components/layout/Layout"; -import { SimpleSDLBuilderForm } from "@src/components/sdl/SimpleSdlBuilderForm"; -import { CustomNextSeo } from "@src/components/shared/CustomNextSeo"; -import { Title } from "@src/components/shared/Title"; -import { withSdlBuilder } from "@src/context/SdlBuilderProvider"; -import { domainName, UrlService } from "@src/utils/urlUtils"; - -function SDLBuilderPage() { - return ( - - - - SDL Builder - - - - ); -} - -export default withSdlBuilder()(SDLBuilderPage); diff --git a/apps/deploy-web/src/queries/useTemplateQuery.spec.tsx b/apps/deploy-web/src/queries/useTemplateQuery.spec.tsx index fbe99c6c10..0e29a8bba4 100644 --- a/apps/deploy-web/src/queries/useTemplateQuery.spec.tsx +++ b/apps/deploy-web/src/queries/useTemplateQuery.spec.tsx @@ -164,8 +164,25 @@ describe("useTemplateQuery", () => { }); }); - function setup(input?: { services?: ServicesProviderProps["services"]; templateId?: string }) { - return setupQuery(() => useTemplate(input?.templateId || "template-1"), { + it("stays idle when no template id is given", async () => { + const consoleApiHttpClient = mock(); + + const { result } = setup({ + hasTemplateId: false, + services: { + consoleApiHttpClient: () => consoleApiHttpClient + } + }); + + await vi.waitFor(() => { + expect(result.current.fetchStatus).toBe("idle"); + }); + expect(consoleApiHttpClient.get).not.toHaveBeenCalled(); + }); + + function setup(input?: { services?: ServicesProviderProps["services"]; templateId?: string; hasTemplateId?: boolean }) { + const templateId = input?.hasTemplateId === false ? undefined : input?.templateId || "template-1"; + return setupQuery(() => useTemplate(templateId), { services: input?.services }); } diff --git a/apps/deploy-web/src/queries/useTemplateQuery.tsx b/apps/deploy-web/src/queries/useTemplateQuery.tsx index a14d3f9349..131087bcde 100644 --- a/apps/deploy-web/src/queries/useTemplateQuery.tsx +++ b/apps/deploy-web/src/queries/useTemplateQuery.tsx @@ -30,12 +30,17 @@ export function useUserFavoriteTemplates(options?: Omit, "queryKey" | "queryFn">) { +/** + * Fetches a user-authored template by id. The API answers with a null body rather than an error when the + * template is neither public nor owned by the caller, so an empty result means "not visible to you". + */ +export function useTemplate(id: string | undefined, options?: Omit, "queryKey" | "queryFn">) { const { consoleApiHttpClient } = useServices(); return useQuery({ - queryKey: QueryKeys.getTemplateKey(id), + queryKey: QueryKeys.getTemplateKey(id ?? ""), queryFn: () => consoleApiHttpClient.get(`/v1/user/template/${id}`).then(response => response.data), + enabled: !!id, ...options }); } diff --git a/apps/deploy-web/src/services/analytics/analytics.service.ts b/apps/deploy-web/src/services/analytics/analytics.service.ts index f5ac1d5c64..dec5085355 100644 --- a/apps/deploy-web/src/services/analytics/analytics.service.ts +++ b/apps/deploy-web/src/services/analytics/analytics.service.ts @@ -57,11 +57,10 @@ export type AnalyticsEvent = | "authorize_spend" | "navigate_tab" | "deploy_sdl" - | "preview_sdl" - | "import_sdl" - | "reset_sdl" + /** Dormant until template saving returns to the configure page (CON-673). */ | "create_sdl_template" | "create_sdl_template_link" + /** Dormant until template saving returns to the configure page (CON-673). */ | "update_sdl_template" | "click_sdl_profile" | "click_view_template" diff --git a/apps/deploy-web/src/store/sdlStore.ts b/apps/deploy-web/src/store/sdlStore.ts index d2604105b2..40e77c0942 100644 --- a/apps/deploy-web/src/store/sdlStore.ts +++ b/apps/deploy-web/src/store/sdlStore.ts @@ -1,16 +1,14 @@ import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; -import type { SdlBuilderFormValuesType, TemplateCreation } from "@src/types"; +import type { TemplateCreation } from "@src/types"; const deploySdl = atom(null); -const sdlBuilderSdl = atom(null); const selectedSdlEditMode = atom<"yaml" | "builder">("yaml"); const sdlPreviewOpen = atomWithStorage("sdlPreviewPaneOpen", false); export default { deploySdl, - sdlBuilderSdl, selectedSdlEditMode, sdlPreviewOpen }; diff --git a/apps/deploy-web/src/utils/sdl/data.spec.ts b/apps/deploy-web/src/utils/sdl/data.spec.ts index 283661aaa5..d7a4e1fa4b 100644 --- a/apps/deploy-web/src/utils/sdl/data.spec.ts +++ b/apps/deploy-web/src/utils/sdl/data.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; -import { UACT_DENOM } from "@src/config/denom.config"; -import { defaultPlacement, defaultPricing, defaultService, healSdlBuilderDraft } from "./data"; +import { defaultPlacement, defaultService } from "./data"; describe("default factories", () => { it("defaultPlacement returns an object with a stable id and a default name", () => { @@ -26,56 +25,3 @@ describe("default factories", () => { expect(b.title).not.toBe("modified"); }); }); - -describe(healSdlBuilderDraft.name, () => { - it("fills default pricing for services missing it and preserves the other fields", () => { - const draft = { - placements: [{ id: "p1", name: "dcloud" }], - services: [{ id: "s1", title: "web", image: "nginx", placementId: "p1" }], - endpoints: [] - }; - - const healed = healSdlBuilderDraft(draft); - - expect(healed.services[0]).toEqual({ - id: "s1", - title: "web", - image: "nginx", - placementId: "p1", - pricing: defaultPricing() - }); - expect(healed.placements).toEqual(draft.placements); - }); - - it("leaves services with valid pricing untouched", () => { - const valid = { id: "s1", title: "web", pricing: { amount: 42, denom: UACT_DENOM } }; - const stale = { id: "s2", title: "api" }; - - const healed = healSdlBuilderDraft({ services: [valid, stale] }); - - expect(healed.services[0]).toBe(valid); - expect(healed.services[1].pricing).toEqual(defaultPricing()); - }); - - it("replaces malformed pricing", () => { - const healed = healSdlBuilderDraft({ services: [{ id: "s1", pricing: { amount: "1" } }] }); - - expect(healed.services[0].pricing).toEqual(defaultPricing()); - }); - - it("returns values unchanged when services is absent or not an array", () => { - const withoutServices = { placements: [] }; - const corrupted = { services: "corrupt" }; - - expect(healSdlBuilderDraft(withoutServices)).toBe(withoutServices); - expect(healSdlBuilderDraft(corrupted)).toBe(corrupted); - }); - - it("gives healed services independent pricing objects", () => { - const healed = healSdlBuilderDraft({ services: [{ id: "s1" }, { id: "s2" }] }); - - healed.services[0].pricing.amount = 1; - - expect(healed.services[1].pricing.amount).toBe(defaultPricing().amount); - }); -}); diff --git a/apps/deploy-web/src/utils/sdl/data.ts b/apps/deploy-web/src/utils/sdl/data.ts index 53e0b6fbc5..d848c6fca4 100644 --- a/apps/deploy-web/src/utils/sdl/data.ts +++ b/apps/deploy-web/src/utils/sdl/data.ts @@ -117,25 +117,6 @@ export const defaultServiceWithPlacement = (serviceOverrides?: Partial): boolean => typeof service.pricing?.amount === "number" && typeof service.pricing?.denom === "string"; - -/** - * Heals sdl-builder drafts persisted to storage before the uact pricing model - * (or corrupted in storage): fills each service's missing/malformed pricing with - * the current default, leaving the rest of the draft untouched. Idempotent and - * safe on malformed input. - */ -export const healSdlBuilderDraft = (values: Record): Record => { - if (!values || !Array.isArray(values.services)) return values; - - return { - ...values, - services: values.services.map(service => - service && typeof service === "object" && !hasValidPricing(service) ? { ...service, pricing: defaultPricing() } : service - ) - }; -}; - export const defaultPersistentStorage = { size: 10, unit: "Gi", diff --git a/apps/deploy-web/src/utils/urlUtils.spec.ts b/apps/deploy-web/src/utils/urlUtils.spec.ts index e2df9f2e20..8935c38d9d 100644 --- a/apps/deploy-web/src/utils/urlUtils.spec.ts +++ b/apps/deploy-web/src/utils/urlUtils.spec.ts @@ -38,4 +38,8 @@ describe(UrlService.configureDeployment.name, () => { it("keeps the draft id alongside the dseq path segment", () => { expect(UrlService.configureDeployment({ dseq: "12345", draftId: "draft-1" })).toBe("/new-deployment/configure/12345?draftId=draft-1"); }); + + it("builds a user template path", () => { + expect(UrlService.configureDeployment({ userTemplateId: "user-1" })).toBe("/new-deployment/configure?userTemplateId=user-1"); + }); }); diff --git a/apps/deploy-web/src/utils/urlUtils.ts b/apps/deploy-web/src/utils/urlUtils.ts index 038e3a4f48..f592b3f0b1 100644 --- a/apps/deploy-web/src/utils/urlUtils.ts +++ b/apps/deploy-web/src/utils/urlUtils.ts @@ -7,7 +7,6 @@ export type NewDeploymentParams = { dseq?: string | number; redeploy?: string | number; templateId?: string; - page?: "new-deployment" | "deploy-linux"; gitProvider?: string; gitProviderCode?: string | null; repoUrl?: string; @@ -22,6 +21,7 @@ export type NewDeploymentParams = { export type ConfigureDeploymentParams = { dseq?: string | number; templateId?: string; + userTemplateId?: string; sdlStrategy?: "default" | "edit"; bidStrategy?: "auto" | "select"; draftId?: string; @@ -56,8 +56,6 @@ export const UrlService = { home: () => "/", getStarted: () => "/get-started", - sdlBuilder: (id?: string) => `/sdl-builder${appendSearchParams({ id })}`, - plainLinux: () => `/deploy-linux`, priceCompare: () => "/price-compare", analytics: () => "/analytics", graph: (snapshot: string) => `/graph/${snapshot}`, @@ -119,14 +117,13 @@ export const UrlService = { buildDirectory, nodeVersion } = params; - const page = params.page || "new-deployment"; - return `/${page}${appendSearchParams({ dseq, step, templateId, redeploy, gitProvider, code: gitProviderCode, repoUrl, branch, buildCommand, startCommand, installCommand, buildDirectory, nodeVersion })}`; + return `/new-deployment${appendSearchParams({ dseq, step, templateId, redeploy, gitProvider, code: gitProviderCode, repoUrl, branch, buildCommand, startCommand, installCommand, buildDirectory, nodeVersion })}`; }, configureDeployment: (params: ConfigureDeploymentParams = {}) => { - const { dseq, templateId, sdlStrategy, bidStrategy, draftId, vm } = params; + const { dseq, templateId, userTemplateId, sdlStrategy, bidStrategy, draftId, vm } = params; const base = dseq ? `/new-deployment/configure/${dseq}` : "/new-deployment/configure"; - return `${base}${appendSearchParams({ templateId, "sdl-strategy": sdlStrategy, "bid-strategy": bidStrategy, draftId, vm })}`; + return `${base}${appendSearchParams({ templateId, userTemplateId, "sdl-strategy": sdlStrategy, "bid-strategy": bidStrategy, draftId, vm })}`; } }; diff --git a/apps/deploy-web/tests/ui/build-template.spec.ts b/apps/deploy-web/tests/ui/build-template.spec.ts deleted file mode 100644 index 75c17ab2bd..0000000000 --- a/apps/deploy-web/tests/ui/build-template.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from "./fixture/base-test"; -import { BuildTemplatePage } from "./pages/BuildTemplatePage"; - -test.use({ userType: "existing" }); - -test("ssh function absence", async ({ page, context }) => { - const sdlBuilderPage = new BuildTemplatePage(context, page); - await sdlBuilderPage.gotoInteractive(); - - await expect(page.getByRole("button", { name: /generate new key/i })).not.toBeVisible(); - await expect(page.getByRole("checkbox", { name: /expose ssh/i })).not.toBeVisible(); - await expect(page.getByRole("combobox", { name: /os image/i })).not.toBeVisible(); - await expect(page.getByLabel(/docker image/i)).toBeVisible(); -}); diff --git a/apps/deploy-web/tests/ui/pages/BuildTemplatePage.tsx b/apps/deploy-web/tests/ui/pages/BuildTemplatePage.tsx deleted file mode 100644 index d08fed2d57..0000000000 --- a/apps/deploy-web/tests/ui/pages/BuildTemplatePage.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { testEnvConfig } from "../fixture/test-env.config"; -import { DeployPage } from "./DeployPage"; - -export class BuildTemplatePage extends DeployPage { - async gotoInteractive() { - await this.page.goto(`${testEnvConfig.BASE_URL}/sdl-builder`); - } - - async addService() { - await this.page.getByRole("button", { name: /add service/i }).click(); - } - - async clickDeploy() { - await this.page.getByRole("button", { name: /^deploy$/i }).click(); - } - - async clickPreview() { - await this.page.getByRole("button", { name: /preview/i }).click(); - } - - getPreviewTextLocator(text: string) { - return this.page.getByText(text).first(); - } - - async closePreview() { - await this.page.getByRole("button", { name: /close/i }).first().click(); - } - - getDeployButton() { - return this.page.getByRole("button", { name: /^deploy$/i }); - } - - getPreviewButton() { - return this.page.getByRole("button", { name: /preview/i }); - } - - getAddServiceButton() { - return this.page.getByRole("button", { name: /add service/i }); - } - - getServiceLocator(serviceName: string) { - const escapedName = serviceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return this.page.getByText(new RegExp(`${escapedName}:`)).first(); - } - - async waitForServiceAdded(serviceName: string, timeout = 10000) { - await this.page.locator(`input[type="text"][value="${serviceName}"]`).first().waitFor({ state: "visible", timeout }); - } -} diff --git a/apps/deploy-web/tests/ui/sdl-builder-deployment.spec.ts b/apps/deploy-web/tests/ui/sdl-builder-deployment.spec.ts deleted file mode 100644 index a4b739cbea..0000000000 --- a/apps/deploy-web/tests/ui/sdl-builder-deployment.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { BrowserContext, Page } from "@playwright/test"; - -import { expect, test } from "./fixture/base-test"; -import { BuildTemplatePage } from "./pages/BuildTemplatePage"; - -test.describe("SDL Builder Deployment Flow", () => { - test.use({ userType: "existing" }); - - test("navigate to SDL builder page", async ({ page, context }) => { - const { sdlBuilderPage } = await setup({ page, context }); - - await expect(sdlBuilderPage.getDeployButton()).toBeVisible(); - await expect(sdlBuilderPage.getPreviewButton()).toBeVisible(); - await expect(sdlBuilderPage.getAddServiceButton()).toBeVisible(); - }); - - test("fill image name and preview SDL", async ({ page, context }) => { - const { sdlBuilderPage } = await setup({ page, context, imageName: "nginx:latest" }); - - await sdlBuilderPage.clickPreview(); - - await expect(sdlBuilderPage.getPreviewTextLocator("nginx:latest")).toBeVisible(); - await expect(sdlBuilderPage.getPreviewTextLocator("version:")).toBeVisible(); - await expect(sdlBuilderPage.getPreviewTextLocator("services:")).toBeVisible(); - - await sdlBuilderPage.closePreview(); - }); - - test("add multiple services", async ({ page, context }) => { - const { sdlBuilderPage } = await setup({ page, context, imageName: "nginx:latest" }); - - await sdlBuilderPage.addService(); - - await sdlBuilderPage.waitForServiceAdded("service-2"); - - await sdlBuilderPage.clickPreview(); - await expect(sdlBuilderPage.getPreviewTextLocator("service-1")).toBeVisible(); - await expect(sdlBuilderPage.getPreviewTextLocator("service-2")).toBeVisible(); - await sdlBuilderPage.closePreview(); - }); - - test("preview SDL with different images", async ({ page, context }) => { - const { sdlBuilderPage } = await setup({ page, context }); - - const images = ["postgres:15", "redis:7", "node:18-alpine"]; - - for (const image of images) { - await sdlBuilderPage.fillImageName(image); - await sdlBuilderPage.clickPreview(); - await expect(sdlBuilderPage.getPreviewTextLocator(image)).toBeVisible(); - await sdlBuilderPage.closePreview(); - } - }); - - test("verify SDL YAML structure", async ({ page, context }) => { - const { sdlBuilderPage } = await setup({ page, context, imageName: "ubuntu:22.04" }); - - await sdlBuilderPage.clickPreview(); - - await expect(sdlBuilderPage.getPreviewTextLocator("version:")).toBeVisible(); - await expect(sdlBuilderPage.getPreviewTextLocator("services:")).toBeVisible(); - await expect(sdlBuilderPage.getPreviewTextLocator("profiles:")).toBeVisible(); - await expect(sdlBuilderPage.getPreviewTextLocator("deployment:")).toBeVisible(); - - await sdlBuilderPage.closePreview(); - }); - - test("add service then preview shows both services", async ({ page, context }) => { - const { sdlBuilderPage } = await setup({ page, context, imageName: "nginx:latest" }); - - await sdlBuilderPage.addService(); - await sdlBuilderPage.waitForServiceAdded("service-2"); - - await sdlBuilderPage.clickPreview(); - - await expect(sdlBuilderPage.getServiceLocator("service-1")).toBeVisible(); - await expect(sdlBuilderPage.getServiceLocator("service-2")).toBeVisible(); - - await sdlBuilderPage.closePreview(); - }); - - test("preview button always available with valid image", async ({ page, context }) => { - const sdlBuilderPage = new BuildTemplatePage(context, page); - await sdlBuilderPage.gotoInteractive(); - - await sdlBuilderPage.fillImageName("alpine:latest"); - - await expect(sdlBuilderPage.getPreviewButton()).toBeEnabled(); - }); - - async function setup({ page, context, imageName }: { page: Page; context: BrowserContext; imageName?: string }) { - const sdlBuilderPage = new BuildTemplatePage(context, page); - await sdlBuilderPage.gotoInteractive(); - - if (imageName) { - await sdlBuilderPage.fillImageName(imageName); - } - - return { sdlBuilderPage }; - } -});