diff --git a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx new file mode 100644 index 0000000000..b1f25a9e8e --- /dev/null +++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx @@ -0,0 +1,234 @@ +import { createStore, Provider as JotaiStoreProvider } from "jotai"; +import { describe, expect, it, vi } from "vitest"; +import type { MockProxy } from "vitest-mock-extended"; +import { mock, mockDeep } from "vitest-mock-extended"; + +import { MAX_DEPLOYMENT_NAME_LENGTH } from "@src/config/deploy.config"; +import type { AppDIContainer } from "@src/context/ServicesProvider/ServicesProvider"; +import type { DeploymentStorageService } from "@src/services/deployment-storage/deployment-storage.service"; +import { settingsIdAtom } from "@src/store/settingsStore"; +import type { DEPENDENCIES } from "./DeploymentNameModal"; +import { DeploymentNameModal } from "./DeploymentNameModal"; + +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TestContainerProvider } from "@tests/unit/TestContainerProvider"; + +describe("DeploymentNameModal", () => { + it("renames the deployment through the api, so the name outlives this browser", async () => { + const { patchMutate } = setup({ resolvedName: "old-name" }); + + await rename("my-app"); + + expect(patchMutate).toHaveBeenCalledWith({ dseq: "12345", data: { name: "my-app" } }, expect.any(Object)); + }); + + it("refreshes what the api holds for the deployment, so the detail page stops showing the old name", async () => { + const { queryClient, api } = setup({}); + + await rename("my-app"); + + await waitFor(() => expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: api.v1.getDeployment.getKey({ dseq: "12345" }) })); + }); + + it("records the new name in this browser too, which the deployments list still reads", async () => { + const { deploymentLocalStorage } = setup({}); + + await rename("my-app"); + + await waitFor(() => expect(deploymentLocalStorage.update).toHaveBeenCalledWith("akash1abc", "12345", { name: "my-app" })); + }); + + it("reports the rename as saved once the api accepted it", async () => { + const { onSaved, enqueueSnackbar } = setup({}); + + await rename("my-app"); + + await waitFor(() => expect(onSaved).toHaveBeenCalledWith("12345")); + expect(enqueueSnackbar).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ variant: "success" })); + }); + + it("reports the deployment it renamed, not the one the dialog has since moved on to", async () => { + let renameOptions: { onSuccess?: () => void } | undefined; + const patchMutate = vi.fn((_variables, options) => { + renameOptions = options; + }); + const { onSaved, showDeployment } = setup({ dseq: "12345", resolvedName: "first-deployment", patchMutate }); + await rename("renamed-first"); + showDeployment({ dseq: "67890", resolvedName: "second-deployment" }); + + act(() => renameOptions?.onSuccess?.()); + + expect(onSaved).toHaveBeenCalledExactlyOnceWith("12345"); + }); + + it("opens on the name the api holds, not only the one this browser recorded", async () => { + setup({ resolvedName: "named-elsewhere" }); + + await waitFor(() => expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("named-elsewhere")); + }); + + it("caps the field at the length the api accepts", () => { + setup({}); + + expect(screen.getByRole("textbox", { name: "Name" })).toHaveAttribute("maxlength", String(MAX_DEPLOYMENT_NAME_LENGTH)); + }); + + it("holds a typed-in over-long name to the length the api accepts, so the rename cannot fail on it", async () => { + const { patchMutate } = setup({ resolvedName: "old-name" }); + + await rename("a".repeat(MAX_DEPLOYMENT_NAME_LENGTH + 10)); + + expect(patchMutate).toHaveBeenCalledWith({ dseq: "12345", data: { name: "a".repeat(MAX_DEPLOYMENT_NAME_LENGTH) } }, expect.any(Object)); + }); + + it("refuses an over-long name that reached the field without passing its own cap", async () => { + const { patchMutate } = setup({ resolvedName: "old-name" }); + + fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "a".repeat(MAX_DEPLOYMENT_NAME_LENGTH + 1) } }); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(patchMutate).not.toHaveBeenCalled(); + }); + + it("ignores a second save while the first is still in flight, so the older name cannot land last", async () => { + const { patchMutate } = setup({ resolvedName: "old-name", isPending: true }); + + await rename("my-app"); + + expect(patchMutate).not.toHaveBeenCalled(); + }); + + it("still refreshes and closes when this browser cannot record the new name", async () => { + const deploymentLocalStorage = mock(); + deploymentLocalStorage.update.mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + const { queryClient, onSaved } = setup({ deploymentLocalStorage }); + + await rename("my-app"); + + await waitFor(() => expect(onSaved).toHaveBeenCalled()); + expect(queryClient.invalidateQueries).toHaveBeenCalled(); + }); + + it.each(["", " "])("refuses a name of %p, which the api rejects rather than reading as unnamed", async typed => { + const { patchMutate, deploymentLocalStorage, onSaved } = setup({ resolvedName: "old-name" }); + + await rename(typed); + + expect(patchMutate).not.toHaveBeenCalled(); + expect(deploymentLocalStorage.update).not.toHaveBeenCalled(); + expect(onSaved).not.toHaveBeenCalled(); + }); + + it("keeps the dialog open and reports a failed rename instead of claiming success", async () => { + const patchMutate = vi.fn((_variables, options) => options?.onError?.(new Error("nope"))); + const { onSaved, deploymentLocalStorage, enqueueSnackbar } = setup({ patchMutate }); + + await rename("my-app"); + + await waitFor(() => expect(enqueueSnackbar).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ variant: "error" }))); + expect(onSaved).not.toHaveBeenCalled(); + expect(deploymentLocalStorage.update).not.toHaveBeenCalled(); + }); + + it("opens on the name of the deployment it was reopened for, not the one typed for another", async () => { + const { showDeployment } = setup({ dseq: "12345", resolvedName: "first-deployment" }); + await type("typed-for-the-first"); + + showDeployment({ dseq: "67890", resolvedName: "second-deployment" }); + + await waitFor(() => expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("second-deployment")); + }); + + it("renames the deployment it was reopened for with that deployment's own name", async () => { + const { showDeployment, patchMutate } = setup({ dseq: "12345", resolvedName: "first-deployment" }); + await type("typed-for-the-first"); + + showDeployment({ dseq: "67890", resolvedName: "second-deployment" }); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(patchMutate).toHaveBeenCalledWith({ dseq: "67890", data: { name: "second-deployment" } }, expect.any(Object)); + }); + + it("opens on the deployment's own name once an edit was abandoned by closing the dialog", async () => { + const { showDeployment } = setup({ dseq: "12345", resolvedName: "old-name" }); + await type("abandoned"); + + showDeployment({ dseq: null, resolvedName: "old-name" }); + showDeployment({ dseq: "12345", resolvedName: "old-name" }); + + await waitFor(() => expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("old-name")); + }); + + it("keeps what the user typed when the api's name for the same deployment arrives afterwards", async () => { + const { showDeployment } = setup({ dseq: "12345" }); + await type("typed-while-loading"); + + showDeployment({ dseq: "12345", resolvedName: "named-elsewhere" }); + + expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("typed-while-loading"); + }); + + async function type(name: string) { + const field = screen.getByRole("textbox", { name: "Name" }); + await userEvent.clear(field); + await userEvent.type(field, name); + } + + async function rename(name: string) { + const field = screen.getByRole("textbox", { name: "Name" }); + await userEvent.clear(field); + if (name) await userEvent.type(field, name); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + } + + function setup(input: { + dseq?: string | null; + resolvedName?: string; + patchMutate?: ReturnType; + isPending?: boolean; + deploymentLocalStorage?: MockProxy; + }) { + const dseq = input.dseq === undefined ? "12345" : input.dseq; + const patchMutate = input.patchMutate ?? vi.fn((_variables, options) => options?.onSuccess?.()); + + const api = mockDeep(); + api.v1.getDeployment.getKey.mockImplementation(request => ["getDeployment", request?.dseq ?? ""]); + api.v1.patchDeployment.useMutation.mockReturnValue( + mock>({ mutate: patchMutate as never, isPending: input.isPending ?? false }) + ); + + const deploymentLocalStorage = input.deploymentLocalStorage ?? mock(); + const queryClient = mock>(); + const enqueueSnackbar = vi.fn(); + const onSaved = vi.fn(); + const onClose = vi.fn(); + let resolvedName = input.resolvedName; + const dependencies: typeof DEPENDENCIES = { + useSnackbar: () => ({ enqueueSnackbar, closeSnackbar: vi.fn() }), + useQueryClient: () => queryClient, + useResolvedDeploymentName: () => resolvedName + }; + + const store = createStore(); + store.set(settingsIdAtom, "akash1abc"); + + const modalFor = (shownDseq: string | number | null) => ( + + api, deploymentLocalStorage: () => deploymentLocalStorage }}> + + + + ); + const { rerender } = render(modalFor(dseq)); + + const showDeployment = (shown: { dseq: string | number | null; resolvedName?: string }) => { + resolvedName = shown.resolvedName; + rerender(modalFor(shown.dseq)); + }; + + return { patchMutate, deploymentLocalStorage, queryClient, enqueueSnackbar, onSaved, onClose, api, showDeployment }; + } +}); diff --git a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx index 833fd5d9e9..b1aecb7443 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx @@ -3,56 +3,100 @@ import { useEffect, useRef } from "react"; import { useForm } from "react-hook-form"; import { Form, FormField, FormInput, Popup, Snackbar } from "@akashnetwork/ui/components"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { useSnackbar } from "notistack"; import { z } from "zod"; +import { MAX_DEPLOYMENT_NAME_LENGTH } from "@src/config/deploy.config"; import { useServices } from "@src/context/ServicesProvider"; +import { useResolvedDeploymentName } from "@src/hooks/useResolvedDeploymentName/useResolvedDeploymentName"; import { settingsIdAtom } from "@src/store/settingsStore"; +export const DEPENDENCIES = { useSnackbar, useQueryClient, useResolvedDeploymentName }; + const formSchema = z.object({ - name: z.string() + name: z + .string() + .trim() + .min(1, "Enter a name for this deployment") + .max(MAX_DEPLOYMENT_NAME_LENGTH, `Use at most ${MAX_DEPLOYMENT_NAME_LENGTH} characters`) }); type Props = { dseq: string | number | null | undefined; onClose: () => void; - onSaved: () => void; - getDeploymentName: (dseq: string | number | null) => string | null; + onSaved: (dseq: string) => void; + dependencies?: typeof DEPENDENCIES; }; -export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, getDeploymentName }) => { - const { deploymentLocalStorage } = useServices(); +export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, dependencies: d = DEPENDENCIES }) => { + const { api, deploymentLocalStorage } = useServices(); const [address] = useAtom(settingsIdAtom); const formRef = useRef(null); - const { enqueueSnackbar } = useSnackbar(); + const { enqueueSnackbar } = d.useSnackbar(); + const queryClient = d.useQueryClient(); + const resolvedName = d.useResolvedDeploymentName(dseq ? String(dseq) : null); + const renameDeployment = api.v1.patchDeployment.useMutation(); const form = useForm>({ defaultValues: { name: "" }, resolver: zodResolver(formSchema) }); - const { handleSubmit, control, setValue } = form; + const { handleSubmit, control, reset, formState } = form; + const isEdited = formState.isDirty; + /** One modal instance serves every deployment, so a name typed for one must never be carried into another's field. */ + const seededDseqRef = useRef(null); - useEffect(() => { - if (dseq) { - const name = getDeploymentName(dseq); - setValue("name", name || ""); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dseq, getDeploymentName]); + useEffect( + function seedFromTheNameOnShow() { + if (!dseq) { + seededDseqRef.current = null; + return; + } + + const shown = String(dseq); + if (seededDseqRef.current !== shown || !isEdited) { + seededDseqRef.current = shown; + reset({ name: resolvedName ?? "" }); + } + }, + [dseq, resolvedName, isEdited, reset] + ); const onSaveClick = (event: React.MouseEvent) => { event.preventDefault(); formRef.current?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true })); }; - function onSubmit({ name }: z.infer) { - deploymentLocalStorage.update(address, dseq, { name: name }); + /** The deployments list still resolves names from this browser alone, so the record is kept in step until it reads the api too — and a full or blocked store must not strand a rename the api has already accepted. */ + function recordNameInThisBrowser(name: string) { + try { + deploymentLocalStorage.update(address, dseq, { name }); + } catch { + return; + } + } - enqueueSnackbar(, { variant: "success", autoHideDuration: 1000 }); + function onSubmit({ name }: z.infer) { + if (!dseq || renameDeployment.isPending) return; + const renamedDseq = String(dseq); - onSaved(); + renameDeployment.mutate( + { dseq: renamedDseq, data: { name } }, + { + onSuccess: function recordRename() { + recordNameInThisBrowser(name); + queryClient.invalidateQueries({ queryKey: api.v1.getDeployment.getKey({ dseq: renamedDseq }) }); + enqueueSnackbar(, { variant: "success", autoHideDuration: 1000 }); + onSaved(renamedDseq); + }, + onError: function reportRenameFailure() { + enqueueSnackbar(, { variant: "error" }); + } + } + ); } return ( @@ -74,6 +118,7 @@ export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, g color: "primary", variant: "default", side: "right", + disabled: renameDeployment.isPending, onClick: onSaveClick } ]} @@ -86,7 +131,7 @@ export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, g control={control} name="name" render={({ field }) => { - return ; + return ; }} /> diff --git a/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.spec.tsx b/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.spec.tsx index ad3312dfff..86e1e6a070 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.spec.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.spec.tsx @@ -48,35 +48,25 @@ describe(LocalNoteManager.name, () => { expect(selectDeployment).toHaveBeenCalledWith(null); }); - it("sets dseq to null when modal onSaved is called", () => { + it("deselects only the deployment whose rename was saved, so a save finishing late cannot close another", () => { const DeploymentNameModalMock = vi.fn(ComponentMock as unknown as typeof DeploymentNameModal); const selectDeployment = vi.fn(); + const deselectDeployment = vi.fn(); setup({ dseq: 789, selectDeployment, + deselectDeployment, dependencies: { DeploymentNameModal: DeploymentNameModalMock } }); act(() => { - DeploymentNameModalMock.mock.calls[0][0].onSaved(); + DeploymentNameModalMock.mock.calls[0][0].onSaved("123"); }); - expect(selectDeployment).toHaveBeenCalledWith(null); - }); - - it("passes getDeploymentName from useLocalNotes to modal", () => { - const DeploymentNameModalMock = vi.fn(ComponentMock as unknown as typeof DeploymentNameModal); - const getDeploymentName = vi.fn().mockReturnValue("my-deployment"); - setup({ - getDeploymentName, - dependencies: { - DeploymentNameModal: DeploymentNameModalMock - } - }); - - expect(DeploymentNameModalMock).toHaveBeenCalledWith(expect.objectContaining({ getDeploymentName }), expect.anything()); + expect(deselectDeployment).toHaveBeenCalledWith("123"); + expect(selectDeployment).not.toHaveBeenCalled(); }); it("initializes favorite providers on mount", () => { @@ -89,12 +79,14 @@ describe(LocalNoteManager.name, () => { function setup(input?: { dseq?: string | number | null; selectDeployment?: (dseq: string | number | null) => void; + deselectDeployment?: (dseq: string | number) => void; getDeploymentName?: (dseq: string | number | null) => string | null; initFavoriteProviders?: () => void; dependencies?: Partial; }) { const dseq = input?.dseq ?? null; const selectDeployment = input?.selectDeployment ?? vi.fn(); + const deselectDeployment = input?.deselectDeployment ?? vi.fn(); const getDeploymentName = input?.getDeploymentName ?? vi.fn().mockReturnValue(null); const initFavoriteProviders = input?.initFavoriteProviders ?? vi.fn(); @@ -104,7 +96,8 @@ describe(LocalNoteManager.name, () => { favoriteProviders: [], updateFavoriteProviders: vi.fn(), selectedDeploymentDseq: dseq, - selectDeployment + selectDeployment, + deselectDeployment }); const useInitFavoriteProviders: typeof DEPENDENCIES.useInitFavoriteProviders = () => initFavoriteProviders; @@ -119,6 +112,6 @@ describe(LocalNoteManager.name, () => { /> ); - return { selectDeployment, getDeploymentName, initFavoriteProviders }; + return { selectDeployment, deselectDeployment, getDeploymentName, initFavoriteProviders }; } }); diff --git a/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.tsx b/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.tsx index 5f420d76b3..905456444c 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.tsx @@ -15,7 +15,7 @@ interface Props { } export function LocalNoteManager({ dependencies: d = DEPENDENCIES }: Props) { - const { getDeploymentName, selectedDeploymentDseq, selectDeployment } = d.useLocalNotes(); + const { selectedDeploymentDseq, selectDeployment, deselectDeployment } = d.useLocalNotes(); const initFavoriteProviders = d.useInitFavoriteProviders(); const resetSelectedDeployment = () => selectDeployment(null); @@ -25,11 +25,6 @@ export function LocalNoteManager({ dependencies: d = DEPENDENCIES }: Props) { }, []); return ( - + ); } diff --git a/apps/deploy-web/src/components/LocalNoteManager/useLocalNotes.spec.tsx b/apps/deploy-web/src/components/LocalNoteManager/useLocalNotes.spec.tsx new file mode 100644 index 0000000000..0334d45c6d --- /dev/null +++ b/apps/deploy-web/src/components/LocalNoteManager/useLocalNotes.spec.tsx @@ -0,0 +1,39 @@ +import { createStore, Provider as JotaiStoreProvider } from "jotai"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { DeploymentStorageService } from "@src/services/deployment-storage/deployment-storage.service"; +import { localNoteStore } from "./localNoteStore"; +import { useLocalNotes } from "./useLocalNotes"; + +import { act } from "@testing-library/react"; +import { setupQuery } from "@tests/unit/query-client"; + +describe(useLocalNotes.name, () => { + it("deselects the deployment whose dseq it is given, however that dseq is typed", () => { + const { result } = setup({ selectedDseq: 789 }); + + act(() => result.current.deselectDeployment("789")); + + expect(result.current.selectedDeploymentDseq).toBeNull(); + }); + + it("keeps a different deployment selected when an earlier one's save completes late", () => { + const { result } = setup({ selectedDseq: 789 }); + + act(() => result.current.deselectDeployment(123)); + + expect(result.current.selectedDeploymentDseq).toBe(789); + }); + + function setup(input: { selectedDseq?: string | number | null }) { + const store = createStore(); + store.set(localNoteStore.deploymentNameDseq, input.selectedDseq ?? null); + const deploymentLocalStorage = mock(); + + return setupQuery(() => useLocalNotes(), { + services: { deploymentLocalStorage: () => deploymentLocalStorage }, + wrapper: ({ children }) => {children} + }); + } +}); diff --git a/apps/deploy-web/src/components/LocalNoteManager/useLocalNotes.ts b/apps/deploy-web/src/components/LocalNoteManager/useLocalNotes.ts index 622155d70f..a4a4ba0a4c 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/useLocalNotes.ts +++ b/apps/deploy-web/src/components/LocalNoteManager/useLocalNotes.ts @@ -14,6 +14,7 @@ export type LocalNotesContextType = { updateFavoriteProviders: (newFavorites: string[]) => void; selectedDeploymentDseq: string | number | null; selectDeployment: (dseq: string | number | null) => void; + deselectDeployment: (dseq: string | number) => void; }; export function useLocalNotes(): LocalNotesContextType { @@ -37,6 +38,14 @@ export function useLocalNotes(): LocalNotesContextType { [selectDeployment] ); + /** A rename that completes after the dialog moved on to another deployment must not close that one. */ + const deselectDeployment = useCallback( + (dseq: string | number) => { + selectDeployment(current => (String(current) === String(dseq) ? null : current)); + }, + [selectDeployment] + ); + const updateFavoriteProviders = useCallback( (newFavorites: string[]) => { updateProviderLocalData({ favorites: newFavorites }); @@ -45,7 +54,7 @@ export function useLocalNotes(): LocalNotesContextType { [setFavoriteProviders] ); - return { getDeploymentName, changeDeploymentName, favoriteProviders, updateFavoriteProviders, selectedDeploymentDseq, selectDeployment }; + return { getDeploymentName, changeDeploymentName, favoriteProviders, updateFavoriteProviders, selectedDeploymentDseq, selectDeployment, deselectDeployment }; } export function useInitFavoriteProviders() { diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.spec.tsx index 0b19a3d31d..28b919e512 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.spec.tsx @@ -460,9 +460,25 @@ describe(ConfigureDeploymentForm.name, () => { await waitFor(() => expect(save).toHaveBeenCalledWith(expect.stringContaining("node:18"), "my-app", undefined)); }); + it("persists only the name this session typed, never one the api derived for it", async () => { + const { save } = setup({ initialSdl: undefined, apiDerivedName: "web+postgres", Panes: SdlProbePanes }); + + await userEvent.click(screen.getByRole("button", { name: "change image" })); + + await waitFor(() => expect(save).toHaveBeenCalledWith(expect.any(String), "", undefined)); + }); + + it("shows the name the api derived while asking for quotes under none, so the api can derive it again", () => { + const { ConfigureDeploymentPanes, ConfigureDeploymentHeader } = setup({ initialSdl: undefined, apiDerivedName: "web+postgres" }); + + expect(ConfigureDeploymentPanes).toHaveBeenCalledWith(expect.objectContaining({ deploymentName: "web+postgres" }), expect.anything()); + expect(ConfigureDeploymentHeader).toHaveBeenCalledWith(expect.objectContaining({ deploymentName: "" }), expect.anything()); + }); + function setup(input: { initialSdl: string | undefined; initialName?: string; + apiDerivedName?: string; Panes?: typeof SdlProbePanes; draftId?: string; persistedRuntimeLimitHours?: number; @@ -503,7 +519,11 @@ describe(ConfigureDeploymentForm.name, () => { clear }) ); - const useDeploymentName = ((args: { initialName?: string }) => ({ name: args.initialName ?? "", setName: setDeploymentName })) as never; + const useDeploymentName = ((args: { initialName?: string }) => ({ + name: input.apiDerivedName ?? args.initialName ?? "", + typedName: args.initialName ?? "", + setName: setDeploymentName + })) as never; // The base flow is created upstream by the DeploymentFlowProvider now, so it arrives as a prop rather than a hook. const flow = mock({ phase: input.phase ?? "configuring", diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.tsx index 937207cb5e..79adb01445 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.tsx @@ -73,7 +73,7 @@ export const ConfigureDeploymentForm: FC = ({ initialSdl, initialName, in const { enqueueSnackbar } = d.useSnackbar(); const { analyticsService } = d.useServices(); const draft = d.useConfigureDraft(intent); - const { name: deploymentName, setName: setDeploymentName } = d.useDeploymentName({ initialName, dseq: flow.dseq }); + const { name: deploymentName, typedName: typedDeploymentName, setName: setDeploymentName } = d.useDeploymentName({ initialName, dseq: flow.dseq }); const [runtimeLimitHours, setRuntimeLimitHours] = useState(() => draft.persistedRuntimeLimitHours); const form = useForm({ defaultValues: initialState.values, @@ -116,13 +116,13 @@ export const ConfigureDeploymentForm: FC = ({ initialSdl, initialName, in function debouncePreviewSdl() { const timeout = setTimeout(function commitDebouncedSdl() { setPreviewSdl(liveSdl); - draft.save(liveSdl, deploymentName, runtimeLimitHours); + draft.save(liveSdl, typedDeploymentName, runtimeLimitHours); }, SDL_SYNC_DEBOUNCE_MS); return function cancelPreviewDebounce() { clearTimeout(timeout); }; }, - [liveSdl, deploymentName, runtimeLimitHours, draft] + [liveSdl, typedDeploymentName, runtimeLimitHours, draft] ); useEffect( @@ -263,6 +263,7 @@ export const ConfigureDeploymentForm: FC = ({ initialSdl, initialName, in openReview(flow.selections)} allPlacementsHaveBids={allPlacementsHaveBids} /> diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx index d4624f5c13..df05858560 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx @@ -24,10 +24,19 @@ describe(ConfigureDeploymentHeader.name, () => { fireEvent.click(screen.getByRole("button", { name: /request quotes/i })); - await waitFor(() => expect(requestQuotes).toHaveBeenCalledWith(GENERATED_SDL)); + await waitFor(() => expect(requestQuotes).toHaveBeenCalledWith(GENERATED_SDL, "")); expect(enqueueSnackbar).not.toHaveBeenCalled(); }); + it("requests quotes with the name typed into the deployment pane, so the api records it on create", async () => { + const requestQuotes = vi.fn(); + setup({ phase: "configuring", requestQuotes, deploymentName: "my-app" }); + + fireEvent.click(screen.getByRole("button", { name: /request quotes/i })); + + await waitFor(() => expect(requestQuotes).toHaveBeenCalledWith(GENERATED_SDL, "my-app")); + }); + it("blocks a trial deployment whose GPU resolves to a blocked selection and surfaces the trial message", async () => { const requestQuotes = vi.fn(); const { enqueueSnackbar } = setup({ @@ -57,7 +66,7 @@ describe(ConfigureDeploymentHeader.name, () => { fireEvent.click(screen.getByRole("button", { name: /request quotes/i })); - await waitFor(() => expect(requestQuotes).toHaveBeenCalledWith(GENERATED_SDL)); + await waitFor(() => expect(requestQuotes).toHaveBeenCalledWith(GENERATED_SDL, "")); }); it("does not apply the trial GPU guard for a non-trial user", async () => { @@ -71,7 +80,7 @@ describe(ConfigureDeploymentHeader.name, () => { fireEvent.click(screen.getByRole("button", { name: /request quotes/i })); - await waitFor(() => expect(requestQuotes).toHaveBeenCalledWith(GENERATED_SDL)); + await waitFor(() => expect(requestQuotes).toHaveBeenCalledWith(GENERATED_SDL, "")); }); it("surfaces SDL validation errors and does not request quotes when the spec is invalid", async () => { @@ -248,6 +257,7 @@ describe(ConfigureDeploymentHeader.name, () => { expiry?: QuoteExpiry | null; cancelAndEdit?: () => void; isRestricted?: boolean; + deploymentName?: string; services?: Array<{ profile: { hasGpu?: boolean; gpuModels?: Array<{ vendor: string; name?: string }> } }>; }) { const flow = mock({ @@ -281,6 +291,7 @@ describe(ConfigureDeploymentHeader.name, () => { void; allPlacementsHaveBids: boolean; dependencies?: typeof DEPENDENCIES }; +type Props = { + flow: DeploymentFlow; + sdl: string; + deploymentName: string; + onDeploy: () => void; + allPlacementsHaveBids: boolean; + dependencies?: typeof DEPENDENCIES; +}; -export const ConfigureDeploymentHeader: FC = ({ flow, sdl, onDeploy, allPlacementsHaveBids, dependencies: d = DEPENDENCIES }) => { +export const ConfigureDeploymentHeader: FC = ({ flow, sdl, deploymentName, onDeploy, allPlacementsHaveBids, dependencies: d = DEPENDENCIES }) => { const deploymentSummary = d.useDeploymentResourceSummary(); const showAsHourly = d.useDeploymentHasGpu(); const { control, handleSubmit, getValues } = useFormContext(); @@ -97,7 +104,7 @@ export const ConfigureDeploymentHeader: FC = ({ flow, sdl, onDeploy, allP ); return; } - flow.actions.requestQuotes(sdl); + flow.actions.requestQuotes(sdl, deploymentName); }); return ( diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentNameField/DeploymentNameField.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentNameField/DeploymentNameField.spec.tsx index 205308dbd4..b46954007c 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentNameField/DeploymentNameField.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentNameField/DeploymentNameField.spec.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { MAX_DEPLOYMENT_NAME_LENGTH } from "@src/config/deploy.config"; import { DeploymentNameField } from "./DeploymentNameField"; import { render, screen } from "@testing-library/react"; @@ -12,6 +13,12 @@ describe("DeploymentNameField", () => { expect(screen.getByRole("textbox", { name: "Deployment name" })).toBeDisabled(); }); + it("caps the name at the length the api accepts, so a long name cannot fail the whole create", () => { + setup({ value: "" }); + + expect(screen.getByRole("textbox", { name: "Deployment name" })).toHaveAttribute("maxlength", String(MAX_DEPLOYMENT_NAME_LENGTH)); + }); + it("reports typed changes", async () => { const { onChange } = setup({ value: "" }); diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentNameField/DeploymentNameField.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentNameField/DeploymentNameField.tsx index 30b073c915..65d5a8f814 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentNameField/DeploymentNameField.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentNameField/DeploymentNameField.tsx @@ -1,6 +1,8 @@ import type { FC } from "react"; import { Input } from "@akashnetwork/ui/components"; +import { MAX_DEPLOYMENT_NAME_LENGTH } from "@src/config/deploy.config"; + type Props = { value: string; onChange: (value: string) => void; @@ -15,6 +17,7 @@ export const DeploymentNameField: FC = ({ value, onChange, disabled }) => inputClassName="h-9" aria-label="Deployment name" placeholder="Name your deployment" + maxLength={MAX_DEPLOYMENT_NAME_LENGTH} value={value} disabled={disabled} onChange={event => onChange(event.target.value)} 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..95143c8a78 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 @@ -38,6 +38,26 @@ describe(useDeploymentFlow.name, () => { expect(replace).toHaveBeenCalledWith("/new-deployment/configure/999?bid-strategy=select", undefined, { shallow: true }); }); + it("carries the typed name into the create request, so the api records the name the user chose", async () => { + const createMutate = vi.fn((_args, { onSuccess }) => onSuccess({ data: { dseq: "999", manifest: "m" } })); + const { result } = setup({ createMutate }); + + act(() => result.current.actions.requestQuotes("sdl-content", " my-app ")); + + await waitFor(() => expect(result.current.phase).toBe("quoting")); + expect(createMutate).toHaveBeenCalledWith({ data: { sdl: "sdl-content", name: "my-app", deposit: expect.any(Number) } }, expect.any(Object)); + }); + + it.each([undefined, "", " "])("omits a name of %p, which the api refuses rather than treating as unnamed", async name => { + const createMutate = vi.fn((_args, { onSuccess }) => onSuccess({ data: { dseq: "999", manifest: "m" } })); + const { result } = setup({ createMutate }); + + act(() => result.current.actions.requestQuotes("sdl-content", name)); + + await waitFor(() => expect(result.current.phase).toBe("quoting")); + expect(createMutate.mock.calls[0][0].data).not.toHaveProperty("name"); + }); + it("mirrors the strategy current when a create resolves, not the one it was fired with", () => { const replace = vi.fn(); let resolveCreate: ((result: { data: { dseq: string; manifest: string } }) => void) | undefined; 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..11f4863e66 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.ts +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.ts @@ -42,9 +42,9 @@ export interface DeploymentFlowState { } export interface DeploymentFlowActions { - /** Creates the deployment from the given SDL. The caller passes the SDL generated from the just-submitted + /** Creates the deployment from the given SDL and name. The caller passes the SDL generated from the just-submitted * form values so the request can never lag behind an in-flight edit. */ - requestQuotes: (sdl: string) => void; + requestQuotes: (sdl: string, name?: string) => void; cancelAndEdit: () => void; setBidStrategy: (strategy: BidStrategy) => void; refreshQuotes: () => void; @@ -252,7 +252,7 @@ export function useDeploymentFlow({ intent }: UseDeploymentFlowInput, dependenci * deployment is still open (a prior close failed), it is closed first so the single-open-deployment invariant holds. */ const requestQuotes = useCallback( - function requestQuotes(sdl: string) { + function requestQuotes(sdl: string, name?: string) { const attempt = ++createAttemptRef.current; providersEverBidRef.current = false; bidsReceivedTrackedRef.current = false; @@ -262,7 +262,7 @@ export function useDeploymentFlow({ intent }: UseDeploymentFlowInput, dependenci if (attempt !== createAttemptRef.current) return; setPhase("creating"); createDeployment.mutate( - { data: { sdl, deposit: DEFAULT_DEPOSIT } }, + { data: { sdl, ...namePayload(name), deposit: DEFAULT_DEPOSIT } }, { onSuccess: function onCreated(result: { data: { dseq: string; manifest: string } }) { if (attempt !== createAttemptRef.current) { @@ -458,6 +458,12 @@ export function useDeploymentFlow({ intent }: UseDeploymentFlowInput, dependenci }; } +/** The api refuses a blank name rather than reading it as "unnamed", so a name the user left empty is left out of the request entirely. */ +function namePayload(name: string | undefined): { name?: string } { + const trimmed = name?.trim(); + return trimmed ? { name: trimmed } : {}; +} + /** Best-effort cache under owner + dseq (the key the detail page reads); failures are swallowed so storage issues never block deploy. */ function cacheDeployedSdl( storage: ReturnType["deploymentLocalStorage"], diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.spec.tsx index a54a3421b3..71f1f11f92 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.spec.tsx @@ -3,6 +3,7 @@ import { createStore, Provider as JotaiStoreProvider } from "jotai"; import { describe, expect, it } from "vitest"; import { mock } from "vitest-mock-extended"; +import { MAX_DEPLOYMENT_NAME_LENGTH } from "@src/config/deploy.config"; import type { DeploymentStorageService } from "@src/services/deployment-storage/deployment-storage.service"; import { settingsIdAtom } from "@src/store/settingsStore"; import type { DEPENDENCIES } from "./useDeploymentName"; @@ -17,6 +18,53 @@ describe(useDeploymentName.name, () => { expect(result.current.name).toBe("my-app"); }); + it("holds a seeded name to the length the api accepts, so a legacy name cannot fail the create", () => { + const { result } = setup({ initialName: "a".repeat(MAX_DEPLOYMENT_NAME_LENGTH + 10) }); + + expect(result.current.name).toBe("a".repeat(MAX_DEPLOYMENT_NAME_LENGTH)); + }); + + it("fills the field from the api when this session typed no name of its own", () => { + const { result } = setup({ dseq: "12345", apiName: "named-elsewhere" }); + + expect(result.current.name).toBe("named-elsewhere"); + }); + + it("keeps a name typed after the deployment exists, so an edit is never discarded", () => { + const { result } = setup({ initialName: "my-app", dseq: "12345", apiName: "named-elsewhere" }); + + act(() => result.current.setName("renamed-before-retrying")); + + expect(result.current.name).toBe("renamed-before-retrying"); + }); + + it("keeps showing the typed name while no deployment exists to carry it", () => { + const { result } = setup({ initialName: "my-app", dseq: null, apiName: "named-elsewhere" }); + + expect(result.current.name).toBe("my-app"); + }); + + it("reports no typed name of its own while the shown one came from the api, so nothing derived is persisted as the user's", () => { + const { result } = setup({ dseq: "12345", apiName: "web+postgres" }); + + expect(result.current.name).toBe("web+postgres"); + expect(result.current.typedName).toBe(""); + }); + + it("reports the typed name as its own once this session types over the api's", () => { + const { result } = setup({ dseq: "12345", apiName: "web+postgres" }); + + act(() => result.current.setName("my-app")); + + expect(result.current.typedName).toBe("my-app"); + }); + + it("resolves to an empty name when neither the api nor this session holds one, leaving the field its placeholder", () => { + const { result } = setup({ dseq: "12345" }); + + expect(result.current.name).toBe(""); + }); + it("updates the name via setName", () => { const { result } = setup({ initialName: "my-app" }); @@ -56,9 +104,10 @@ describe(useDeploymentName.name, () => { expect(deploymentLocalStorage.update).toHaveBeenCalledWith("akash1abc", "12345", { name: "my-app" }); }); - function setup(input: { initialName?: string; dseq?: string | null; settingsId?: string | null }) { + function setup(input: { initialName?: string; dseq?: string | null; settingsId?: string | null; apiName?: string }) { const deploymentLocalStorage = mock(); const useServices: typeof DEPENDENCIES.useServices = () => mock>({ deploymentLocalStorage }); + const useResolvedDeploymentName: typeof DEPENDENCIES.useResolvedDeploymentName = dseq => (dseq ? input.apiName : undefined); const store = createStore(); store.set(settingsIdAtom, input.settingsId ?? null); @@ -66,7 +115,10 @@ describe(useDeploymentName.name, () => { const initialProps = { initialName: input.initialName, dseq: input.dseq ?? null }; return { - ...renderHook((props: { initialName?: string; dseq: string | null }) => useDeploymentName(props, { useServices }), { wrapper, initialProps }), + ...renderHook((props: { initialName?: string; dseq: string | null }) => useDeploymentName(props, { useServices, useResolvedDeploymentName }), { + wrapper, + initialProps + }), deploymentLocalStorage, store }; diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts index 27cb862910..aab3798c9a 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts @@ -1,14 +1,18 @@ import { useEffect, useRef, useState } from "react"; import { useAtomValue } from "jotai"; +import { MAX_DEPLOYMENT_NAME_LENGTH } from "@src/config/deploy.config"; import { useServices } from "@src/context/ServicesProvider"; +import { useResolvedDeploymentName } from "@src/hooks/useResolvedDeploymentName/useResolvedDeploymentName"; import { settingsIdAtom } from "@src/store/settingsStore"; -export const DEPENDENCIES = { useServices }; +export const DEPENDENCIES = { useServices, useResolvedDeploymentName }; export interface DeploymentName { - /** The current deployment name; seeded from `initialName`, edited via `setName`. */ + /** The name to show: the one typed in this session, and the api's own only where this session has none. */ name: string; + /** The name this session typed, and only that: what the draft records and the next create carries, so a name the api derived is never persisted as the user's own nor sent back as one. */ + typedName: string; setName: (name: string) => void; } @@ -19,22 +23,15 @@ interface UseDeploymentNameInput { dseq: string | null; } -/** - * Owns the configure session's deployment name: its state (seeded once from `initialName`) and its write to the - * wallet-scoped local record the rest of the app reads via `useLocalNotes.getDeploymentName(dseq)`. The record is - * keyed by `settingsId` — which WalletProvider sets to the wallet address — so the name surfaces on the deployment - * list/detail pages after deploy, the same store the legacy builder wrote to. It deliberately avoids `useWallet()`, - * so no new dependency on the wallet provider is introduced. The write happens once `dseq` and `settingsId` are both - * present (the deployment is created for a known wallet); until the wallet-scoped key exists the write is deferred - * rather than dropped. A session that resumed already carrying a `dseq` has it present from mount and is treated as - * already-written, so a resume never clobbers a name the user may have since edited on the deployment page. - */ +/** Owns the configure session's deployment name: the api's own once the deployment exists, the typed one before that, and the write of the typed one to the wallet-scoped local record `settingsId` keys. */ export function useDeploymentName({ initialName, dseq }: UseDeploymentNameInput, dependencies = DEPENDENCIES): DeploymentName { const { deploymentLocalStorage } = dependencies.useServices(); const settingsId = useAtomValue(settingsIdAtom); - const [name, setName] = useState(() => initialName ?? ""); - const nameRef = useRef(name); - nameRef.current = name; + const [typedName, setTypedName] = useState(() => (initialName ?? "").slice(0, MAX_DEPLOYMENT_NAME_LENGTH)); + const nameRef = useRef(typedName); + nameRef.current = typedName; + const resolvedName = dependencies.useResolvedDeploymentName(dseq); + /** Seeded with the mounting `dseq`, so a session resumed already carrying one is treated as written and never clobbers a name edited since on the deployment page. */ const writtenDseqRef = useRef(dseq); useEffect( function persistNameOnCreate() { @@ -45,5 +42,5 @@ export function useDeploymentName({ initialName, dseq }: UseDeploymentNameInput, }, [dseq, settingsId, deploymentLocalStorage] ); - return { name, setName }; + return { name: typedName || resolvedName || "", typedName, setName: setTypedName }; } diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetailHeader.spec.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetailHeader.spec.tsx index 9630891a07..2ee8230a0a 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetailHeader.spec.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentDetailHeader.spec.tsx @@ -48,13 +48,13 @@ describe(DeploymentDetailHeader.name, () => { expect(screen.getByText("3")).toBeInTheDocument(); }); - it("shows the deployment name recorded for this deployment in this browser", () => { + it("shows the resolved deployment name", () => { setup({ name: "My Storefront" }); expect(screen.getByText("My Storefront")).toBeInTheDocument(); }); - it("falls back to a generated name when none is stored", () => { + it("falls back to a generated name when neither the api nor this browser holds one", () => { setup({ name: null }); expect(screen.getByText("Deployment #1786440078202")).toBeInTheDocument(); diff --git a/apps/deploy-web/src/config/deploy.config.ts b/apps/deploy-web/src/config/deploy.config.ts index bd7bbd18cf..0354d01304 100644 --- a/apps/deploy-web/src/config/deploy.config.ts +++ b/apps/deploy-web/src/config/deploy.config.ts @@ -1 +1,4 @@ export const USER_TEMPLATE_CODE = "USER_TEMPLATE"; + +/** Mirrors `DeploymentNameSchema` in apps/api, which refuses a longer name and fails the whole create or rename rather than just the name. */ +export const MAX_DEPLOYMENT_NAME_LENGTH = 256; diff --git a/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.spec.tsx b/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.spec.tsx index d5ed118b0e..d8eb3fbce4 100644 --- a/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.spec.tsx +++ b/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.spec.tsx @@ -4,6 +4,7 @@ import { QueryCache, QueryClient } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; +import { useResolvedDeploymentName } from "@src/hooks/useResolvedDeploymentName/useResolvedDeploymentName"; import type { DeploymentStorageService } from "@src/services/deployment-storage/deployment-storage.service"; import type { DEPENDENCIES } from "./useDeploymentDefinition"; import { isUsableDeploymentDefinition, useDeploymentDefinition } from "./useDeploymentDefinition"; @@ -34,14 +35,21 @@ describe(useDeploymentDefinition.name, () => { expect(result.current.sdl).toBeUndefined(); }); - it("carries the deployment name while the sdl is still resolving, since the name is never the api's to answer", () => { + it("carries the name from this browser while the api's answer is still resolving", () => { const { result } = setup({ apiSdl: API_SDL, localName: "my-deployment" }); expect(result.current.source).toBe("resolving"); expect(result.current.name).toBe("my-deployment"); }); - it("carries the deployment name from this browser even when the sdl comes from the api", async () => { + it("prefers the name the api holds over the one this browser recorded", async () => { + const { result } = setup({ apiSdl: API_SDL, apiName: "renamed-elsewhere", localSdl: LOCAL_SDL, localName: "my-deployment" }); + + await vi.waitFor(() => expect(result.current.name).toBe("renamed-elsewhere")); + expect(result.current.source).toBe("api"); + }); + + it("carries the name from this browser when the api holds none", async () => { const { result } = setup({ apiSdl: API_SDL, localSdl: LOCAL_SDL, localName: "my-deployment" }); await vi.waitFor(() => expect(result.current.source).toBe("api")); @@ -131,6 +139,7 @@ describe(useDeploymentDefinition.name, () => { function setup(input: { dseq?: string | null; apiSdl?: string | null; + apiName?: string; apiError?: Error; chainManifestVersion?: string; recordedManifestVersion?: string; @@ -144,6 +153,7 @@ describe(useDeploymentDefinition.name, () => { return Promise.resolve({ data: { deployment: { hash: chainManifestVersion }, + name: input.apiName ?? null, consoleSettings: input.apiSdl ? { sdl: input.apiSdl, manifestVersion: recordedManifestVersion } : null } }); @@ -165,9 +175,14 @@ describe(useDeploymentDefinition.name, () => { const services = { api, deploymentLocalStorage } satisfies Partial>; const useServices: typeof DEPENDENCIES.useServices = () => services as unknown as ReturnType; - const { result } = setupQuery(() => useDeploymentDefinition(input.dseq === undefined ? "123" : input.dseq, { useServices, useWallet }), { - services: { api: () => api, deploymentLocalStorage: () => deploymentLocalStorage, queryClient: () => queryClient } - }); + const useResolvedName: typeof DEPENDENCIES.useResolvedDeploymentName = dseq => useResolvedDeploymentName(dseq, { useServices }); + + const { result } = setupQuery( + () => useDeploymentDefinition(input.dseq === undefined ? "123" : input.dseq, { useServices, useWallet, useResolvedDeploymentName: useResolvedName }), + { + services: { api: () => api, deploymentLocalStorage: () => deploymentLocalStorage, queryClient: () => queryClient } + } + ); return { result, getDeployment, deploymentLocalStorage, onQueryError }; } diff --git a/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.ts b/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.ts index 9dd8397e55..d936542996 100644 --- a/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.ts +++ b/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.ts @@ -3,6 +3,7 @@ import { ApiError } from "@akashnetwork/openapi-sdk"; import { useServices } from "@src/context/ServicesProvider"; import { useWallet } from "@src/context/WalletProvider"; +import { useResolvedDeploymentName } from "@src/hooks/useResolvedDeploymentName/useResolvedDeploymentName"; import { isStoredSdlSelfContained } from "@src/utils/sdl/storedDefinition"; /** `absent` still carries the API's copy when it held one it could not stand behind, so the shape is visible even though the values are not. */ @@ -21,7 +22,7 @@ export function isUsableDeploymentDefinition(definition: DeploymentDefinition): return !!definition.sdl && USABLE_SOURCES.includes(definition.source); } -export const DEPENDENCIES = { useServices, useWallet }; +export const DEPENDENCIES = { useServices, useWallet, useResolvedDeploymentName }; /** A deployment's SDL, from the console API when that copy is the one the chain is running, and from this browser otherwise. */ export function useDeploymentDefinition(dseq: string | undefined | null, dependencies = DEPENDENCIES): DeploymentDefinition { @@ -51,9 +52,8 @@ export function useDeploymentDefinition(dseq: string | undefined | null, depende * and serving it would present a superseded document as authoritative and re-ship it on the next update. */ const isApiCopyOnChain = !!consoleSettings?.manifestVersion && consoleSettings.manifestVersion === query.data?.deployment?.hash; - const stored = deploymentLocalStorage.get(address, dseq); - const localSdl = stored?.manifest; - const name = stored?.name; + const localSdl = deploymentLocalStorage.get(address, dseq)?.manifest; + const name = dependencies.useResolvedDeploymentName(dseq); return useMemo(() => { if (isResolving) return { sdl: undefined, name, source: "resolving" }; diff --git a/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx new file mode 100644 index 0000000000..24a6877931 --- /dev/null +++ b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx @@ -0,0 +1,120 @@ +import { ApiError } from "@akashnetwork/openapi-sdk"; +import { createProxy } from "@akashnetwork/react-query-proxy"; +import { QueryCache, QueryClient } from "@tanstack/react-query"; +import { createStore, Provider as JotaiStoreProvider } from "jotai"; +import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { DeploymentStorageService } from "@src/services/deployment-storage/deployment-storage.service"; +import { settingsIdAtom } from "@src/store/settingsStore"; +import type { DEPENDENCIES } from "./useResolvedDeploymentName"; +import { useResolvedDeploymentName } from "./useResolvedDeploymentName"; + +import { type RenderAppHookOptions, setupQuery } from "@tests/unit/query-client"; + +type ApiService = ReturnType["api"]>>; + +describe(useResolvedDeploymentName.name, () => { + it("prefers the name the api holds over the one this browser recorded", async () => { + const { result } = setup({ apiName: "api-name", localName: "local-name" }); + + await vi.waitFor(() => expect(result.current).toBe("api-name")); + }); + + it("asks the api for the deployment the caller named", async () => { + const { getDeployment } = setup({ apiName: "api-name" }); + + await vi.waitFor(() => expect(getDeployment).toHaveBeenCalledWith({ dseq: "123" })); + }); + + it("falls back to this browser's record when the api holds no name", async () => { + const { result, getDeployment } = setup({ apiName: null, localName: "local-name" }); + + await vi.waitFor(() => expect(getDeployment).toHaveBeenCalled()); + expect(result.current).toBe("local-name"); + }); + + it("reads this browser's record under the wallet the app recorded, with no wallet provider above the hook", async () => { + const { result, deploymentLocalStorage } = setup({ apiName: null, localName: "local-name" }); + + await vi.waitFor(() => expect(result.current).toBe("local-name")); + expect(deploymentLocalStorage.get).toHaveBeenCalledWith("akash1test", "123"); + }); + + it("serves this browser's record while the api's answer is still resolving", () => { + const { result } = setup({ apiName: "api-name", localName: "local-name" }); + + expect(result.current).toBe("local-name"); + }); + + it("resolves to nothing when neither holds a name, leaving the caller its own placeholder", async () => { + const { result, getDeployment } = setup({ apiName: null }); + + await vi.waitFor(() => expect(getDeployment).toHaveBeenCalled()); + expect(result.current).toBeUndefined(); + }); + + it.each([401, 403, 404])("recovers a %s into this browser's record instead of failing the query", async status => { + const { result, onQueryError, queryStatus } = setup({ + apiError: new ApiError(status, {}, `GET /v1/deployments/{dseq} → ${status}`), + localName: "local-name" + }); + + await vi.waitFor(() => expect(queryStatus()).toBe("success")); + expect(result.current).toBe("local-name"); + expect(onQueryError).not.toHaveBeenCalled(); + }); + + it("reports a server error rather than silencing it, and still falls back", async () => { + const { result, onQueryError, queryStatus } = setup({ apiError: new ApiError(500, {}, "GET /v1/deployments/{dseq} → 500"), localName: "local-name" }); + + await vi.waitFor(() => expect(onQueryError).toHaveBeenCalled()); + expect(queryStatus()).toBe("error"); + expect(result.current).toBe("local-name"); + }); + + it("asks the api for nothing when there is no dseq", () => { + const { result, getDeployment } = setup({ dseq: null, apiName: "api-name", localName: "local-name" }); + + expect(getDeployment).not.toHaveBeenCalled(); + expect(result.current).toBeUndefined(); + }); + + function setup(input: { dseq?: string | null; apiName?: string | null; apiError?: Error; localName?: string }) { + const getDeployment = vi.fn(() => { + if (input.apiError) return Promise.reject(input.apiError); + return Promise.resolve({ data: { name: input.apiName ?? null } }); + }); + const api = createProxy({ v1: { getDeployment } }) as unknown as ApiService; + + const deploymentLocalStorage = mock({ + get: vi.fn((address, dseq) => (address && dseq && input.localName ? { name: input.localName } : null)) + }); + + const onQueryError = vi.fn(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false } }, + queryCache: new QueryCache({ onError: onQueryError }) + }); + + /** `satisfies` type-checks both fields against the real container, but `api` is a recursive proxy that `mock()` recurses into until the heap dies. */ + const services = { api, deploymentLocalStorage } satisfies Partial>; + const useServices: typeof DEPENDENCIES.useServices = () => services as unknown as ReturnType; + + const store = createStore(); + store.set(settingsIdAtom, "akash1test"); + + const { result } = setupQuery(() => useResolvedDeploymentName(input.dseq === undefined ? "123" : input.dseq, { useServices }), { + services: { api: () => api, deploymentLocalStorage: () => deploymentLocalStorage, queryClient: () => queryClient }, + wrapper: ({ children }) => {children} + }); + + return { + result, + getDeployment, + deploymentLocalStorage, + onQueryError, + queryStatus: () => queryClient.getQueryState(api.v1.getDeployment.getKey({ dseq: "123" }))?.status + }; + } +}); diff --git a/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts new file mode 100644 index 0000000000..4c5c42a5c1 --- /dev/null +++ b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts @@ -0,0 +1,28 @@ +import { ApiError } from "@akashnetwork/openapi-sdk"; +import { useAtomValue } from "jotai"; + +import { useServices } from "@src/context/ServicesProvider"; +import { settingsIdAtom } from "@src/store/settingsStore"; + +export const DEPENDENCIES = { useServices }; + +/** A deployment's name as the console api holds it, falling back to this browser's own record only where the api holds none. */ +export function useResolvedDeploymentName(dseq: string | undefined | null, dependencies = DEPENDENCIES): string | undefined { + const { api, deploymentLocalStorage } = dependencies.useServices(); + /** Read from the store rather than `useWallet`, since the rename dialog mounts outside the wallet provider and would resolve no address there at all. */ + const address = useAtomValue(settingsIdAtom); + + const query = api.v1.getDeployment.useQuery( + { dseq: dseq ?? "" }, + { + enabled: !!dseq, + /** A server fault is reported like any other; a refusal or an offline browser is neither a bug nor a reason to leave a named deployment unnamed. */ + catchError(error) { + if (error instanceof ApiError && error.status >= 500) throw error; + return null; + } + } + ); + + return query.data?.data.name ?? deploymentLocalStorage.get(address, dseq)?.name; +}