From 3a649f40cdcdf8d7b65d31997ed3db8b2076cfa6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:13:36 +0200 Subject: [PATCH] ui(deploy): router blocker + DS dialog for unsaved edits - Add deployedConfigSnapshot field to Deployment interface (mirrors server shape) - Remove the Task 10.3 cast in handleRestore now that the type has the field - New useUnsavedChangesBlocker hook (react-router useBlocker, v7.13.1) - Wire AlertDialog into AppDeploymentPage for in-app navigation guard Co-Authored-By: Claude Sonnet 4.6 --- ui/src/api/queries/admin/apps.ts | 5 ++++ .../hooks/useUnsavedChangesBlocker.ts | 26 +++++++++++++++++++ .../pages/AppsTab/AppDeploymentPage/index.tsx | 18 ++++++++++--- 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 ui/src/pages/AppsTab/AppDeploymentPage/hooks/useUnsavedChangesBlocker.ts 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 ── */} + ); }