From 92fc09aa5d0017bf6662d8d28e907d498df09cb1 Mon Sep 17 00:00:00 2001 From: Console Developer <1159966+stalniy@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:26:21 +0000 Subject: [PATCH 1/9] feat(deployment): prefer the api's deployment name over this browser's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deployment's name now lives on the deployment itself, but deploy-web still reads it only from this browser's localStorage, so a user on a second device or one who cleared their storage sees a placeholder where the deployment has a name. Resolve the name through a single precedence — the console api's name, then this browser's own record, then the caller's existing placeholder — behind `useResolvedDeploymentName`. `useDeploymentDefinition` delegates its `name` to it, so the detail header resolves through the api over the `getDeployment` query it already runs, and the configure session's name resolves the same way once the deployment exists, which also recovers the name for a session resumed in a browser holding no draft. This is the read half: the writes still go to localStorage. Co-Authored-By: Claude Opus 5 --- .../useDeploymentName.spec.tsx | 26 +++++- .../useDeploymentName/useDeploymentName.ts | 26 ++---- .../DeploymentDetailHeader.spec.tsx | 4 +- .../useDeploymentDefinition.spec.tsx | 25 ++++- .../useDeploymentDefinition.ts | 8 +- .../useResolvedDeploymentName.spec.tsx | 92 +++++++++++++++++++ .../useResolvedDeploymentName.ts | 27 ++++++ 7 files changed, 179 insertions(+), 29 deletions(-) create mode 100644 apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx create mode 100644 apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts 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..2f6b97adba 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 @@ -17,6 +17,24 @@ describe(useDeploymentName.name, () => { expect(result.current.name).toBe("my-app"); }); + it("shows the name the api holds once the deployment exists", () => { + const { result } = setup({ initialName: "my-app", dseq: "12345", apiName: "named-elsewhere" }); + + expect(result.current.name).toBe("named-elsewhere"); + }); + + 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("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 +74,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 +85,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..5994c523dd 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts @@ -2,12 +2,13 @@ import { useEffect, useRef, useState } from "react"; import { useAtomValue } from "jotai"; 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 api's own once the deployment exists, and the one typed in this session before that. */ name: string; setName: (name: string) => void; } @@ -19,22 +20,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 ?? ""); + 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 +39,5 @@ export function useDeploymentName({ initialName, dseq }: UseDeploymentNameInput, }, [dseq, settingsId, deploymentLocalStorage] ); - return { name, setName }; + return { name: 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/hooks/useDeploymentDefinition/useDeploymentDefinition.spec.tsx b/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.spec.tsx index d5ed118b0e..b63b523443 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, useWallet }); + + 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..d858d815a0 --- /dev/null +++ b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx @@ -0,0 +1,92 @@ +import { ApiError } from "@akashnetwork/openapi-sdk"; +import { createProxy } from "@akashnetwork/react-query-proxy"; +import { QueryCache, QueryClient } from "@tanstack/react-query"; +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 type { DEPENDENCIES } from "./useResolvedDeploymentName"; +import { useResolvedDeploymentName } from "./useResolvedDeploymentName"; + +import { buildWallet } from "@tests/seeders/wallet"; +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("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("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])("falls back to this browser's record on a %s without reporting it", async status => { + const { result, onQueryError } = setup({ apiError: new ApiError(status, {}, `GET /v1/deployments/{dseq} → ${status}`), localName: "local-name" }); + + await vi.waitFor(() => 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 } = setup({ apiError: new ApiError(500, {}, "GET /v1/deployments/{dseq} → 500"), localName: "local-name" }); + + await vi.waitFor(() => expect(onQueryError).toHaveBeenCalled()); + 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) => (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 }) + }); + + const useWallet: typeof DEPENDENCIES.useWallet = () => buildWallet({ address: "akash1test" }); + /** `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 { result } = setupQuery(() => useResolvedDeploymentName(input.dseq === undefined ? "123" : input.dseq, { useServices, useWallet }), { + services: { api: () => api, deploymentLocalStorage: () => deploymentLocalStorage, queryClient: () => queryClient } + }); + + return { result, getDeployment, deploymentLocalStorage, onQueryError }; + } +}); 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..583c7a481a --- /dev/null +++ b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts @@ -0,0 +1,27 @@ +import { ApiError } from "@akashnetwork/openapi-sdk"; + +import { useServices } from "@src/context/ServicesProvider"; +import { useWallet } from "@src/context/WalletProvider"; + +export const DEPENDENCIES = { useServices, useWallet }; + +/** 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(); + const { address } = dependencies.useWallet(); + + 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; + }, + select: response => response?.data?.name ?? null + } + ); + + return query.data ?? deploymentLocalStorage.get(address, dseq)?.name; +} From 7f08e478ac53742591b19b42c0988ab61e80e37d Mon Sep 17 00:00:00 2001 From: Console Developer <1159966+stalniy@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:53:28 +0000 Subject: [PATCH 2/9] feat(deployment): name the deployment the configure flow creates The api names a deployment after the services its SDL declares whenever the create request carries no name of its own, so with the name reaching only localStorage the api held `web+postgres` for a deployment the user named `my-app`. Now that deploy-web prefers the api's name, that derived name won the bid screening field and the detail header back from the user the moment the deployment was created. Carry the typed name into the create request. A name that is blank or only spaces is left out of the payload entirely rather than sent as an empty string, which the api refuses rather than reading as unnamed. Co-Authored-By: Claude Opus 5 --- .../ConfigureDeploymentForm.tsx | 1 + .../ConfigureDeploymentHeader.spec.tsx | 17 +++++++++++++--- .../ConfigureDeploymentHeader.tsx | 13 +++++++++--- .../useDeploymentFlow.spec.tsx | 20 +++++++++++++++++++ .../useDeploymentFlow/useDeploymentFlow.ts | 14 +++++++++---- 5 files changed, 55 insertions(+), 10 deletions(-) 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..282dbe0a61 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.tsx @@ -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/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"], From 2effa62927a7d13740e840b6feca8573d639ed57 Mon Sep 17 00:00:00 2001 From: Console Developer <1159966+stalniy@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:53:37 +0000 Subject: [PATCH 3/9] feat(deployment): rename a deployment through the api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every deployment the api creates carries a name, so the header — which now prefers the api's name — showed that name over the one a rename wrote to localStorage. The rename reported success, the deployments list picked it up, and the detail page it was performed on did not. Rename through PATCH /v1/deployments/{dseq}, which records a name and neither broadcasts nor pushes a manifest, then invalidate the deployment the header reads. The local record is still written alongside, because the deployments list resolves names from this browser alone until it reads the api too. An empty name is now refused rather than silently clearing the name, which PATCH rejects, and a failed rename leaves the dialog open instead of reporting success. Co-Authored-By: Claude Opus 5 --- .../DeploymentNameModal.spec.tsx | 112 ++++++++++++++++++ .../LocalNoteManager/DeploymentNameModal.tsx | 34 ++++-- 2 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx 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..2c26553f7c --- /dev/null +++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx @@ -0,0 +1,112 @@ +import { createStore, Provider as JotaiStoreProvider } from "jotai"; +import { describe, expect, it, vi } from "vitest"; +import { mock, mockDeep } from "vitest-mock-extended"; + +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 { 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({ storedName: "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).toHaveBeenCalled()); + expect(enqueueSnackbar).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ variant: "success" })); + }); + + it.each(["", " "])("refuses a name of %p, which the api rejects rather than reading as unnamed", async typed => { + const { patchMutate, deploymentLocalStorage, onSaved } = setup({ storedName: "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(); + }); + + 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; storedName?: string | null; patchMutate?: ReturnType }) { + 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 }) + ); + + const deploymentLocalStorage = mock(); + const queryClient = mock>(); + const enqueueSnackbar = vi.fn(); + const onSaved = vi.fn(); + const onClose = vi.fn(); + const getDeploymentName = () => input.storedName ?? null; + + const dependencies: typeof DEPENDENCIES = { + useSnackbar: () => ({ enqueueSnackbar, closeSnackbar: vi.fn() }), + useQueryClient: () => queryClient + }; + + const store = createStore(); + store.set(settingsIdAtom, "akash1abc"); + + render( + + api, deploymentLocalStorage: () => deploymentLocalStorage }}> + + + + ); + + return { patchMutate, deploymentLocalStorage, queryClient, enqueueSnackbar, onSaved, onClose, api }; + } +}); diff --git a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx index 833fd5d9e9..8603d4ff11 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx @@ -3,6 +3,7 @@ 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"; @@ -10,8 +11,10 @@ import { z } from "zod"; import { useServices } from "@src/context/ServicesProvider"; import { settingsIdAtom } from "@src/store/settingsStore"; +export const DEPENDENCIES = { useSnackbar, useQueryClient }; + const formSchema = z.object({ - name: z.string() + name: z.string().trim().min(1, "Enter a name for this deployment") }); type Props = { @@ -19,13 +22,16 @@ type Props = { onClose: () => void; onSaved: () => void; getDeploymentName: (dseq: string | number | null) => string | null; + dependencies?: typeof DEPENDENCIES; }; -export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, getDeploymentName }) => { - const { deploymentLocalStorage } = useServices(); +export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, getDeploymentName, 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 renameDeployment = api.v1.patchDeployment.useMutation(); const form = useForm>({ defaultValues: { name: "" @@ -48,11 +54,23 @@ export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, g }; function onSubmit({ name }: z.infer) { - deploymentLocalStorage.update(address, dseq, { name: name }); - - enqueueSnackbar(, { variant: "success", autoHideDuration: 1000 }); + if (!dseq) return; - onSaved(); + renameDeployment.mutate( + { dseq: String(dseq), data: { name } }, + { + onSuccess: function recordRename() { + /** The deployments list still resolves names from this browser alone, so the record is kept in step until it reads the api too. */ + deploymentLocalStorage.update(address, dseq, { name }); + queryClient.invalidateQueries({ queryKey: api.v1.getDeployment.getKey({ dseq: String(dseq) }) }); + enqueueSnackbar(, { variant: "success", autoHideDuration: 1000 }); + onSaved(); + }, + onError: function reportRenameFailure() { + enqueueSnackbar(, { variant: "error" }); + } + } + ); } return ( From 84d0debb168087861417c22b39ea5222c2dbf40b Mon Sep 17 00:00:00 2001 From: Console Developer <1159966+stalniy@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:22:31 +0000 Subject: [PATCH 4/9] refactor(deployment): read the resolved name where a bad read is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The null the recovery hands back was unpacked inside react-query's `select`, which swallows what it throws: the observer turns an error into `data: undefined`, the cache's `onError` never fires, and this hook reads only `data`. A refusal that stopped resolving would have looked exactly like a deployment with no name. Unpack the response where it is read instead, so a bad read fails the render rather than degrading in silence. The optional chain `data` never needed goes with it: the field is required on a 200. Two assertions the tests were missing: that the hook asks for the deployment the caller named, and that a refusal leaves the query successful rather than merely leaving the name looking right — the previous test settled on the browser's record before the request had even finished. Co-Authored-By: Claude Opus 5 --- .../useResolvedDeploymentName.spec.tsx | 27 +++++++++++++++---- .../useResolvedDeploymentName.ts | 5 ++-- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx index d858d815a0..05840c7e92 100644 --- a/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx +++ b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx @@ -20,6 +20,12 @@ describe(useResolvedDeploymentName.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" }); @@ -40,17 +46,22 @@ describe(useResolvedDeploymentName.name, () => { expect(result.current).toBeUndefined(); }); - it.each([401, 403, 404])("falls back to this browser's record on a %s without reporting it", async status => { - const { result, onQueryError } = setup({ apiError: new ApiError(status, {}, `GET /v1/deployments/{dseq} → ${status}`), localName: "local-name" }); + 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(result.current).toBe("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 } = setup({ apiError: new ApiError(500, {}, "GET /v1/deployments/{dseq} → 500"), localName: "local-name" }); + 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"); }); @@ -87,6 +98,12 @@ describe(useResolvedDeploymentName.name, () => { services: { api: () => api, deploymentLocalStorage: () => deploymentLocalStorage, queryClient: () => queryClient } }); - return { result, getDeployment, deploymentLocalStorage, onQueryError }; + 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 index 583c7a481a..ab8530ffe8 100644 --- a/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts +++ b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts @@ -18,10 +18,9 @@ export function useResolvedDeploymentName(dseq: string | undefined | null, depen catchError(error) { if (error instanceof ApiError && error.status >= 500) throw error; return null; - }, - select: response => response?.data?.name ?? null + } } ); - return query.data ?? deploymentLocalStorage.get(address, dseq)?.name; + return query.data?.data.name ?? deploymentLocalStorage.get(address, dseq)?.name; } From 9b8b92282f4430574a25559897aeadb0cc7b1721 Mon Sep 17 00:00:00 2001 From: Console Developer <1159966+stalniy@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:49:32 +0000 Subject: [PATCH 5/9] fix(deployment): hold a deployment name to what the api accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing the name into the create and rename requests put it in front of `DeploymentNameSchema`, which caps it at 256 characters and fails the whole request rather than just the name. Nothing in the UI capped it, so a long name went from a harmless localStorage write to a deployment that would not create. Cap both fields, cap a name seeded from an older record, and validate the rename form independently of the field. Three more faults the same two writes introduced: A name typed after the deployment exists was discarded. The field showed `resolvedName ?? typedName`, so once created the api's name won over every keystroke — including in the error phase, where the pane leaves the field enabled and a retry would have shipped the stale name. A second save while the first was in flight raced it, and the api's `upsertName` keeps whichever lands last. Saving is now refused while a rename is pending. The rename dialog opened on the localStorage name while the heading above it showed the api's, so a deployment named on another device opened blank. It now opens on the same name the heading shows. And a rename the api had already accepted no longer strands on a full or blocked store: the local mirror write is guarded, as `cacheDeployedSdl` already does for the same hazard. Co-Authored-By: Claude Opus 5 --- .../DeploymentNameModal.spec.tsx | 77 ++++++++++++++++--- .../LocalNoteManager/DeploymentNameModal.tsx | 47 +++++++---- .../LocalNoteManager.spec.tsx | 13 ---- .../LocalNoteManager/LocalNoteManager.tsx | 9 +-- .../DeploymentNameField.spec.tsx | 7 ++ .../DeploymentNameField.tsx | 3 + .../useDeploymentName.spec.tsx | 19 ++++- .../useDeploymentName/useDeploymentName.ts | 7 +- apps/deploy-web/src/config/deploy.config.ts | 3 + 9 files changed, 134 insertions(+), 51 deletions(-) diff --git a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx index 2c26553f7c..9f0f400c5a 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx @@ -1,20 +1,22 @@ 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 { render, screen, waitFor } from "@testing-library/react"; +import { 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({ storedName: "old-name" }); + const { patchMutate } = setup({ resolvedName: "old-name" }); await rename("my-app"); @@ -46,8 +48,58 @@ describe("DeploymentNameModal", () => { expect(enqueueSnackbar).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ variant: "success" })); }); + 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({ storedName: "old-name" }); + const { patchMutate, deploymentLocalStorage, onSaved } = setup({ resolvedName: "old-name" }); await rename(typed); @@ -74,26 +126,31 @@ describe("DeploymentNameModal", () => { await userEvent.click(screen.getByRole("button", { name: "Save" })); } - function setup(input: { dseq?: string | null; storedName?: string | null; patchMutate?: ReturnType }) { + 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 }) + mock>({ mutate: patchMutate as never, isPending: input.isPending ?? false }) ); - const deploymentLocalStorage = mock(); + const deploymentLocalStorage = input.deploymentLocalStorage ?? mock(); const queryClient = mock>(); const enqueueSnackbar = vi.fn(); const onSaved = vi.fn(); const onClose = vi.fn(); - const getDeploymentName = () => input.storedName ?? null; - const dependencies: typeof DEPENDENCIES = { useSnackbar: () => ({ enqueueSnackbar, closeSnackbar: vi.fn() }), - useQueryClient: () => queryClient + useQueryClient: () => queryClient, + useResolvedDeploymentName: () => input.resolvedName }; const store = createStore(); @@ -102,7 +159,7 @@ describe("DeploymentNameModal", () => { render( api, deploymentLocalStorage: () => deploymentLocalStorage }}> - + ); diff --git a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx index 8603d4ff11..ec3ab1b273 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx @@ -8,29 +8,35 @@ 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 }; +export const DEPENDENCIES = { useSnackbar, useQueryClient, useResolvedDeploymentName }; const formSchema = z.object({ - name: z.string().trim().min(1, "Enter a name for this deployment") + 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; dependencies?: typeof DEPENDENCIES; }; -export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, getDeploymentName, dependencies: d = DEPENDENCIES }) => { +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 } = d.useSnackbar(); const queryClient = d.useQueryClient(); + const resolvedName = d.useResolvedDeploymentName(dseq ? String(dseq) : null); const renameDeployment = api.v1.patchDeployment.useMutation(); const form = useForm>({ defaultValues: { @@ -38,30 +44,38 @@ export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, g }, resolver: zodResolver(formSchema) }); - const { handleSubmit, control, setValue } = form; + const { handleSubmit, control, setValue, formState } = form; + const isEdited = formState.isDirty; - 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 && !isEdited) setValue("name", resolvedName ?? ""); + }, + [dseq, resolvedName, isEdited, setValue] + ); const onSaveClick = (event: React.MouseEvent) => { event.preventDefault(); formRef.current?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true })); }; + /** 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; + } + } + function onSubmit({ name }: z.infer) { - if (!dseq) return; + if (!dseq || renameDeployment.isPending) return; renameDeployment.mutate( { dseq: String(dseq), data: { name } }, { onSuccess: function recordRename() { - /** The deployments list still resolves names from this browser alone, so the record is kept in step until it reads the api too. */ - deploymentLocalStorage.update(address, dseq, { name }); + recordNameInThisBrowser(name); queryClient.invalidateQueries({ queryKey: api.v1.getDeployment.getKey({ dseq: String(dseq) }) }); enqueueSnackbar(, { variant: "success", autoHideDuration: 1000 }); onSaved(); @@ -92,6 +106,7 @@ export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, g color: "primary", variant: "default", side: "right", + disabled: renameDeployment.isPending, onClick: onSaveClick } ]} @@ -104,7 +119,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..92400046c9 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.spec.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.spec.tsx @@ -66,19 +66,6 @@ describe(LocalNoteManager.name, () => { 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()); - }); - it("initializes favorite providers on mount", () => { const initFavoriteProviders = vi.fn(); setup({ initFavoriteProviders }); diff --git a/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.tsx b/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.tsx index 5f420d76b3..50b5104b12 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 } = 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/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/useDeploymentName/useDeploymentName.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.spec.tsx index 2f6b97adba..709b2024eb 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,12 +18,26 @@ describe(useDeploymentName.name, () => { expect(result.current.name).toBe("my-app"); }); - it("shows the name the api holds once the deployment exists", () => { - const { result } = setup({ initialName: "my-app", dseq: "12345", apiName: "named-elsewhere" }); + 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" }); 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 5994c523dd..b5cc96a68d 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts @@ -1,6 +1,7 @@ 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"; @@ -8,7 +9,7 @@ import { settingsIdAtom } from "@src/store/settingsStore"; export const DEPENDENCIES = { useServices, useResolvedDeploymentName }; export interface DeploymentName { - /** The name to show: the api's own once the deployment exists, and the one typed in this session before that. */ + /** The name to show: the one typed in this session, and the api's own only where this session has none. */ name: string; setName: (name: string) => void; } @@ -24,7 +25,7 @@ interface UseDeploymentNameInput { export function useDeploymentName({ initialName, dseq }: UseDeploymentNameInput, dependencies = DEPENDENCIES): DeploymentName { const { deploymentLocalStorage } = dependencies.useServices(); const settingsId = useAtomValue(settingsIdAtom); - const [typedName, setTypedName] = useState(() => initialName ?? ""); + const [typedName, setTypedName] = useState(() => (initialName ?? "").slice(0, MAX_DEPLOYMENT_NAME_LENGTH)); const nameRef = useRef(typedName); nameRef.current = typedName; const resolvedName = dependencies.useResolvedDeploymentName(dseq); @@ -39,5 +40,5 @@ export function useDeploymentName({ initialName, dseq }: UseDeploymentNameInput, }, [dseq, settingsId, deploymentLocalStorage] ); - return { name: resolvedName ?? typedName, setName: setTypedName }; + return { name: typedName || resolvedName || "", setName: setTypedName }; } 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; From 59ac4e7ff62df17bebd15084d81a36bc39bcf55a Mon Sep 17 00:00:00 2001 From: Console Developer <1159966+stalniy@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:28:23 +0000 Subject: [PATCH 6/9] fix(deployment): open the rename dialog on the name of the deployment it was opened for The dialog is one instance reused for every deployment, and its field was re-seeded only while the form was untouched. Since the form is never reset, one edit left it dirty for the rest of the session, so opening the dialog for another deployment kept the earlier typed name and could rename that deployment to it. Co-Authored-By: Claude Opus 5 --- .../DeploymentNameModal.spec.tsx | 59 +++++++++++++++++-- .../LocalNoteManager/DeploymentNameModal.tsx | 17 +++++- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx index 9f0f400c5a..9d9ee65444 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx @@ -119,6 +119,50 @@ describe("DeploymentNameModal", () => { 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); @@ -147,23 +191,30 @@ describe("DeploymentNameModal", () => { 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: () => input.resolvedName + useResolvedDeploymentName: () => resolvedName }; const store = createStore(); store.set(settingsIdAtom, "akash1abc"); - render( + 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 }; + 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 ec3ab1b273..9b3e81c646 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx @@ -44,14 +44,25 @@ export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, d }, resolver: zodResolver(formSchema) }); - const { handleSubmit, control, setValue, formState } = 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( function seedFromTheNameOnShow() { - if (dseq && !isEdited) setValue("name", resolvedName ?? ""); + if (!dseq) { + seededDseqRef.current = null; + return; + } + + const shown = String(dseq); + if (seededDseqRef.current !== shown || !isEdited) { + seededDseqRef.current = shown; + reset({ name: resolvedName ?? "" }); + } }, - [dseq, resolvedName, isEdited, setValue] + [dseq, resolvedName, isEdited, reset] ); const onSaveClick = (event: React.MouseEvent) => { From 1c10571e0f2f448a5dc9562f0504e0608ce8be03 Mon Sep 17 00:00:00 2001 From: Console Developer <1159966+stalniy@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:53:17 +0000 Subject: [PATCH 7/9] fix(deployment): resolve a deployment name against the wallet the app recorded The fallback to this browser's record read the address from useWallet, but the rename dialog mounts outside the wallet provider, so the address was undefined there and the record was never found: a deployment named only in this browser opened the dialog with an empty field it then refused to save. The settings id the same dialog already writes under is readable from any mount, so the read and the write now share one source. Co-Authored-By: Claude Opus 5 --- .../useDeploymentDefinition.spec.tsx | 2 +- .../useResolvedDeploymentName.spec.tsx | 21 ++++++++++++++----- .../useResolvedDeploymentName.ts | 8 ++++--- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.spec.tsx b/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.spec.tsx index b63b523443..d8eb3fbce4 100644 --- a/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.spec.tsx +++ b/apps/deploy-web/src/hooks/useDeploymentDefinition/useDeploymentDefinition.spec.tsx @@ -175,7 +175,7 @@ describe(useDeploymentDefinition.name, () => { const services = { api, deploymentLocalStorage } satisfies Partial>; const useServices: typeof DEPENDENCIES.useServices = () => services as unknown as ReturnType; - const useResolvedName: typeof DEPENDENCIES.useResolvedDeploymentName = dseq => useResolvedDeploymentName(dseq, { useServices, useWallet }); + const useResolvedName: typeof DEPENDENCIES.useResolvedDeploymentName = dseq => useResolvedDeploymentName(dseq, { useServices }); const { result } = setupQuery( () => useDeploymentDefinition(input.dseq === undefined ? "123" : input.dseq, { useServices, useWallet, useResolvedDeploymentName: useResolvedName }), diff --git a/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx index 05840c7e92..24a6877931 100644 --- a/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx +++ b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.spec.tsx @@ -1,14 +1,15 @@ 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 { buildWallet } from "@tests/seeders/wallet"; import { type RenderAppHookOptions, setupQuery } from "@tests/unit/query-client"; type ApiService = ReturnType["api"]>>; @@ -33,6 +34,13 @@ describe(useResolvedDeploymentName.name, () => { 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" }); @@ -80,7 +88,7 @@ describe(useResolvedDeploymentName.name, () => { const api = createProxy({ v1: { getDeployment } }) as unknown as ApiService; const deploymentLocalStorage = mock({ - get: vi.fn((_address, dseq) => (dseq && input.localName ? { name: input.localName } : null)) + get: vi.fn((address, dseq) => (address && dseq && input.localName ? { name: input.localName } : null)) }); const onQueryError = vi.fn(); @@ -89,13 +97,16 @@ describe(useResolvedDeploymentName.name, () => { queryCache: new QueryCache({ onError: onQueryError }) }); - const useWallet: typeof DEPENDENCIES.useWallet = () => buildWallet({ address: "akash1test" }); /** `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 { result } = setupQuery(() => useResolvedDeploymentName(input.dseq === undefined ? "123" : input.dseq, { useServices, useWallet }), { - services: { api: () => api, deploymentLocalStorage: () => deploymentLocalStorage, queryClient: () => queryClient } + 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 { diff --git a/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts index ab8530ffe8..4c5c42a5c1 100644 --- a/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts +++ b/apps/deploy-web/src/hooks/useResolvedDeploymentName/useResolvedDeploymentName.ts @@ -1,14 +1,16 @@ import { ApiError } from "@akashnetwork/openapi-sdk"; +import { useAtomValue } from "jotai"; import { useServices } from "@src/context/ServicesProvider"; -import { useWallet } from "@src/context/WalletProvider"; +import { settingsIdAtom } from "@src/store/settingsStore"; -export const DEPENDENCIES = { useServices, useWallet }; +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(); - const { address } = dependencies.useWallet(); + /** 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 ?? "" }, From 1f86aa2a9f8a7c3584270688cc923115d6f397a4 Mon Sep 17 00:00:00 2001 From: Console Developer <1159966+stalniy@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:53:17 +0000 Subject: [PATCH 8/9] fix(deployment): persist and resubmit only the name the configure session typed The name the form persisted into its draft was the displayed one, which falls back to the name the api derived for a deployment left unnamed. After a reload that derived name returned as the session's typed name and was sent as an explicit name on the next create, so a deployment built from different services carried a name describing the old ones. The draft and the create request now take the typed name alone, while the field still shows the api's. Co-Authored-By: Claude Opus 5 --- .../ConfigureDeploymentForm.spec.tsx | 22 ++++++++++++++++++- .../ConfigureDeploymentForm.tsx | 8 +++---- .../useDeploymentName.spec.tsx | 15 +++++++++++++ .../useDeploymentName/useDeploymentName.ts | 4 +++- 4 files changed, 43 insertions(+), 6 deletions(-) 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 282dbe0a61..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,7 +263,7 @@ export const ConfigureDeploymentForm: FC = ({ initialSdl, initialName, in openReview(flow.selections)} allPlacementsHaveBids={allPlacementsHaveBids} /> 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 709b2024eb..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 @@ -44,6 +44,21 @@ describe(useDeploymentName.name, () => { 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" }); 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 b5cc96a68d..aab3798c9a 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts @@ -11,6 +11,8 @@ export const DEPENDENCIES = { useServices, useResolvedDeploymentName }; export interface DeploymentName { /** 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; } @@ -40,5 +42,5 @@ export function useDeploymentName({ initialName, dseq }: UseDeploymentNameInput, }, [dseq, settingsId, deploymentLocalStorage] ); - return { name: typedName || resolvedName || "", setName: setTypedName }; + return { name: typedName || resolvedName || "", typedName, setName: setTypedName }; } From 9c553f6685ccf1b5c2b1f5d1ba10d4eddde4a829 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:50:27 +0400 Subject: [PATCH 9/9] fix(deployment): deselect only the deployment whose rename was saved --- .../DeploymentNameModal.spec.tsx | 18 ++++++++- .../LocalNoteManager/DeploymentNameModal.tsx | 9 +++-- .../LocalNoteManager.spec.tsx | 16 +++++--- .../LocalNoteManager/LocalNoteManager.tsx | 4 +- .../LocalNoteManager/useLocalNotes.spec.tsx | 39 +++++++++++++++++++ .../LocalNoteManager/useLocalNotes.ts | 11 +++++- 6 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 apps/deploy-web/src/components/LocalNoteManager/useLocalNotes.spec.tsx diff --git a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx index 9d9ee65444..b1f25a9e8e 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx @@ -10,7 +10,7 @@ import { settingsIdAtom } from "@src/store/settingsStore"; import type { DEPENDENCIES } from "./DeploymentNameModal"; import { DeploymentNameModal } from "./DeploymentNameModal"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { TestContainerProvider } from "@tests/unit/TestContainerProvider"; @@ -44,10 +44,24 @@ describe("DeploymentNameModal", () => { await rename("my-app"); - await waitFor(() => expect(onSaved).toHaveBeenCalled()); + 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" }); diff --git a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx index 9b3e81c646..b1aecb7443 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx @@ -26,7 +26,7 @@ const formSchema = z.object({ type Props = { dseq: string | number | null | undefined; onClose: () => void; - onSaved: () => void; + onSaved: (dseq: string) => void; dependencies?: typeof DEPENDENCIES; }; @@ -81,15 +81,16 @@ export const DeploymentNameModal: React.FC = ({ dseq, onClose, onSaved, d function onSubmit({ name }: z.infer) { if (!dseq || renameDeployment.isPending) return; + const renamedDseq = String(dseq); renameDeployment.mutate( - { dseq: String(dseq), data: { name } }, + { dseq: renamedDseq, data: { name } }, { onSuccess: function recordRename() { recordNameInThisBrowser(name); - queryClient.invalidateQueries({ queryKey: api.v1.getDeployment.getKey({ dseq: String(dseq) }) }); + queryClient.invalidateQueries({ queryKey: api.v1.getDeployment.getKey({ dseq: renamedDseq }) }); enqueueSnackbar(, { variant: "success", autoHideDuration: 1000 }); - onSaved(); + onSaved(renamedDseq); }, onError: function reportRenameFailure() { enqueueSnackbar(, { variant: "error" }); diff --git a/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.spec.tsx b/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.spec.tsx index 92400046c9..86e1e6a070 100644 --- a/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.spec.tsx +++ b/apps/deploy-web/src/components/LocalNoteManager/LocalNoteManager.spec.tsx @@ -48,22 +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); + expect(deselectDeployment).toHaveBeenCalledWith("123"); + expect(selectDeployment).not.toHaveBeenCalled(); }); it("initializes favorite providers on mount", () => { @@ -76,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(); @@ -91,7 +96,8 @@ describe(LocalNoteManager.name, () => { favoriteProviders: [], updateFavoriteProviders: vi.fn(), selectedDeploymentDseq: dseq, - selectDeployment + selectDeployment, + deselectDeployment }); const useInitFavoriteProviders: typeof DEPENDENCIES.useInitFavoriteProviders = () => initFavoriteProviders; @@ -106,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 50b5104b12..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 { selectedDeploymentDseq, selectDeployment } = d.useLocalNotes(); + const { selectedDeploymentDseq, selectDeployment, deselectDeployment } = d.useLocalNotes(); const initFavoriteProviders = d.useInitFavoriteProviders(); const resetSelectedDeployment = () => selectDeployment(null); @@ -25,6 +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() {