diff --git a/ui/src/api/queries/admin/apps.ts b/ui/src/api/queries/admin/apps.ts index 188f220c..12b0fa25 100644 --- a/ui/src/api/queries/admin/apps.ts +++ b/ui/src/api/queries/admin/apps.ts @@ -41,6 +41,11 @@ export interface Deployment { deployedAt: string | null; stoppedAt: string | null; createdAt: string; + deployedConfigSnapshot?: { + jarVersionId: string; + agentConfig: Record | null; + containerConfig: Record; + } | null; } /** diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useUnsavedChangesBlocker.ts b/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useUnsavedChangesBlocker.ts new file mode 100644 index 00000000..4493981e --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/hooks/useUnsavedChangesBlocker.ts @@ -0,0 +1,26 @@ +import { useState, useEffect } from 'react'; +import { useBlocker } from 'react-router'; + +export function useUnsavedChangesBlocker(hasUnsavedChanges: boolean) { + const blocker = useBlocker(({ currentLocation, nextLocation }) => + hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname + ); + + const [dialogOpen, setDialogOpen] = useState(false); + + useEffect(() => { + if (blocker.state === 'blocked') setDialogOpen(true); + }, [blocker.state]); + + return { + dialogOpen, + confirm: () => { + setDialogOpen(false); + blocker.proceed?.(); + }, + cancel: () => { + setDialogOpen(false); + blocker.reset?.(); + }, + }; +} diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx index 4713b41f..cc39b5d8 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx @@ -30,6 +30,7 @@ import { DeploymentTab } from './DeploymentTab/DeploymentTab'; import { PrimaryActionButton, computeMode } from './PrimaryActionButton'; import { useDeploymentPageState } from './hooks/useDeploymentPageState'; import { useFormDirty } from './hooks/useFormDirty'; +import { useUnsavedChangesBlocker } from './hooks/useUnsavedChangesBlocker'; import { deriveAppName } from './utils/deriveAppName'; import styles from './AppDeploymentPage.module.css'; @@ -108,6 +109,8 @@ export default function AppDeploymentPage() { // Derived const mode = app ? 'deployed' : 'net-new'; const dirty = useFormDirty(form, serverState, stagedJar); + const { dialogOpen: blockerOpen, confirm: blockerConfirm, cancel: blockerCancel } = + useUnsavedChangesBlocker(dirty.anyLocalEdit); const serverDirtyAgainstDeploy = dirtyState?.dirty ?? false; const deploymentInProgress = !!activeDeployment; const primaryMode = computeMode({ @@ -302,9 +305,7 @@ export default function AppDeploymentPage() { } function handleRestore(deploymentId: string) { - const deployment = deployments.find((d) => d.id === deploymentId) as - | (Deployment & { deployedConfigSnapshot?: { agentConfig?: Record; containerConfig?: Record } }) - | undefined; + const deployment = deployments.find((d) => d.id === deploymentId); if (!deployment) return; const snap = deployment.deployedConfigSnapshot; if (!snap) return; @@ -519,6 +520,17 @@ export default function AppDeploymentPage() { confirmLabel="Delete" variant="danger" /> + + {/* ── Unsaved changes navigation blocker ── */} + ); }