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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 23:13:36 +02:00
parent b1bdb88ea4
commit 3a649f40cd
3 changed files with 46 additions and 3 deletions

View File

@@ -41,6 +41,11 @@ export interface Deployment {
deployedAt: string | null; deployedAt: string | null;
stoppedAt: string | null; stoppedAt: string | null;
createdAt: string; createdAt: string;
deployedConfigSnapshot?: {
jarVersionId: string;
agentConfig: Record<string, unknown> | null;
containerConfig: Record<string, unknown>;
} | null;
} }
/** /**

View File

@@ -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?.();
},
};
}

View File

@@ -30,6 +30,7 @@ import { DeploymentTab } from './DeploymentTab/DeploymentTab';
import { PrimaryActionButton, computeMode } from './PrimaryActionButton'; import { PrimaryActionButton, computeMode } from './PrimaryActionButton';
import { useDeploymentPageState } from './hooks/useDeploymentPageState'; import { useDeploymentPageState } from './hooks/useDeploymentPageState';
import { useFormDirty } from './hooks/useFormDirty'; import { useFormDirty } from './hooks/useFormDirty';
import { useUnsavedChangesBlocker } from './hooks/useUnsavedChangesBlocker';
import { deriveAppName } from './utils/deriveAppName'; import { deriveAppName } from './utils/deriveAppName';
import styles from './AppDeploymentPage.module.css'; import styles from './AppDeploymentPage.module.css';
@@ -108,6 +109,8 @@ export default function AppDeploymentPage() {
// Derived // Derived
const mode = app ? 'deployed' : 'net-new'; const mode = app ? 'deployed' : 'net-new';
const dirty = useFormDirty(form, serverState, stagedJar); const dirty = useFormDirty(form, serverState, stagedJar);
const { dialogOpen: blockerOpen, confirm: blockerConfirm, cancel: blockerCancel } =
useUnsavedChangesBlocker(dirty.anyLocalEdit);
const serverDirtyAgainstDeploy = dirtyState?.dirty ?? false; const serverDirtyAgainstDeploy = dirtyState?.dirty ?? false;
const deploymentInProgress = !!activeDeployment; const deploymentInProgress = !!activeDeployment;
const primaryMode = computeMode({ const primaryMode = computeMode({
@@ -302,9 +305,7 @@ export default function AppDeploymentPage() {
} }
function handleRestore(deploymentId: string) { function handleRestore(deploymentId: string) {
const deployment = deployments.find((d) => d.id === deploymentId) as const deployment = deployments.find((d) => d.id === deploymentId);
| (Deployment & { deployedConfigSnapshot?: { agentConfig?: Record<string, unknown>; containerConfig?: Record<string, unknown> } })
| undefined;
if (!deployment) return; if (!deployment) return;
const snap = deployment.deployedConfigSnapshot; const snap = deployment.deployedConfigSnapshot;
if (!snap) return; if (!snap) return;
@@ -519,6 +520,17 @@ export default function AppDeploymentPage() {
confirmLabel="Delete" confirmLabel="Delete"
variant="danger" variant="danger"
/> />
{/* ── Unsaved changes navigation blocker ── */}
<AlertDialog
open={blockerOpen}
onClose={blockerCancel}
onConfirm={blockerConfirm}
title="Unsaved changes"
description="You have unsaved changes on this page. Discard and leave?"
confirmLabel="Discard & Leave"
variant="warning"
/>
</div> </div>
); );
} }