feat(ui): Add confirmation dialog for unsaved changes in extension modal (#2971)

Co-authored-by: Alex Rouse <alex.rouse@block.xyz>
This commit is contained in:
Alex Rouse
2025-06-17 19:06:06 -04:00
committed by GitHub
parent 0f09bbcbaa
commit 398a4fe237
2 changed files with 138 additions and 55 deletions
@@ -10,6 +10,7 @@ interface EnvVarsSectionProps {
onRemove: (index: number) => void;
onChange: (index: number, field: 'key' | 'value', value: string) => void;
submitAttempted: boolean;
onPendingInputChange?: (hasPendingInput: boolean) => void;
}
export default function EnvVarsSection({
@@ -18,6 +19,7 @@ export default function EnvVarsSection({
onRemove,
onChange,
submitAttempted,
onPendingInputChange,
}: EnvVarsSectionProps) {
const [newKey, setNewKey] = React.useState('');
const [newValue, setNewValue] = React.useState('');
@@ -27,6 +29,12 @@ export default function EnvVarsSection({
value: false,
});
// Notify parent when pending input changes
React.useEffect(() => {
const hasPendingInput = newKey.trim() !== '' || newValue.trim() !== '';
onPendingInputChange?.(hasPendingInput);
}, [newKey, newValue, onPendingInputChange]);
const handleAdd = () => {
const keyEmpty = !newKey.trim();
const valueEmpty = !newValue.trim();
@@ -8,6 +8,7 @@ import { PlusIcon, Edit, Trash2, AlertTriangle } from 'lucide-react';
import ExtensionInfoFields from './ExtensionInfoFields';
import ExtensionTimeoutField from './ExtensionTimeoutField';
import { upsertConfig } from '../../../../api/sdk.gen';
import { ConfirmationModal } from '../../../ui/ConfirmationModal';
interface ExtensionModalProps {
title: string;
@@ -31,6 +32,65 @@ export default function ExtensionModal({
const [formData, setFormData] = useState<ExtensionFormData>(initialData);
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [showCloseConfirmation, setShowCloseConfirmation] = useState(false);
const [hasPendingEnvVars, setHasPendingEnvVars] = useState(false);
// Function to check if form has been modified
const hasFormChanges = (): boolean => {
// Check if command/endpoint has changed
const commandChanged =
(formData.type === 'stdio' && formData.cmd !== initialData.cmd) ||
(formData.type === 'sse' && formData.endpoint !== initialData.endpoint);
// Check if any environment variables have been modified
const envVarsChanged = formData.envVars.some((envVar) => envVar.isEdited === true);
// Check if new env vars have been added
const envVarsAdded = formData.envVars.length > initialData.envVars.length;
// Check if env vars have been removed
const envVarsRemoved = formData.envVars.length < initialData.envVars.length;
// Check if any environment variable fields have text entered (even if not marked as edited)
const envVarsHaveText = formData.envVars.some(
(envVar) =>
(envVar.key.trim() !== '' || envVar.value.trim() !== '') &&
// Don't count placeholder values for existing secrets
envVar.value !== '••••••••'
);
// Check if there are pending environment variables being typed
const hasPendingInput = hasPendingEnvVars;
return (
commandChanged ||
envVarsChanged ||
envVarsAdded ||
envVarsRemoved ||
envVarsHaveText ||
hasPendingInput
);
};
// Handle backdrop close with confirmation if needed
const handleBackdropClose = () => {
if (hasFormChanges()) {
setShowCloseConfirmation(true);
} else {
onClose();
}
};
// Handle confirmed close
const handleConfirmClose = () => {
setShowCloseConfirmation(false);
onClose();
};
// Handle cancel close confirmation
const handleCancelClose = () => {
setShowCloseConfirmation(false);
};
const handleAddEnvVar = (key: string, value: string) => {
setFormData({
@@ -208,7 +268,7 @@ export default function ExtensionModal({
{submitLabel}
</Button>
<Button
onClick={onClose}
onClick={handleBackdropClose}
variant="ghost"
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-md font-regular"
>
@@ -221,69 +281,84 @@ export default function ExtensionModal({
const modalTitle = showDeleteConfirmation ? `Delete Extension "${formData.name}"` : title;
return (
<Modal footer={footerContent} onClose={onClose}>
{/* Title and Icon */}
<div className="flex flex-col mb-6">
<div>{getModalIcon()}</div>
<div className="mt-2">
<h2 className="text-2xl font-regular text-textStandard">{modalTitle}</h2>
<>
<Modal footer={footerContent} onClose={handleBackdropClose}>
{/* Title and Icon */}
<div className="flex flex-col mb-6">
<div>{getModalIcon()}</div>
<div className="mt-2">
<h2 className="text-2xl font-regular text-textStandard">{modalTitle}</h2>
</div>
</div>
</div>
{showDeleteConfirmation ? (
<div className="mb-6">
<p className="text-textStandard">
This will permanently remove this extension and all of its settings.
</p>
</div>
) : (
<>
{/* Form Fields */}
{/* Name and Type */}
<ExtensionInfoFields
name={formData.name}
type={formData.type}
description={formData.description}
onChange={(key, value) => setFormData({ ...formData, [key]: value })}
submitAttempted={submitAttempted}
/>
{/* Divider */}
<hr className="border-t border-borderSubtle mb-4" />
{/* Command */}
{showDeleteConfirmation ? (
<div className="mb-6">
<ExtensionConfigFields
<p className="text-textStandard">
This will permanently remove this extension and all of its settings.
</p>
</div>
) : (
<>
{/* Form Fields */}
{/* Name and Type */}
<ExtensionInfoFields
name={formData.name}
type={formData.type}
full_cmd={formData.cmd || ''}
endpoint={formData.endpoint || ''}
onChange={(key, value) => setFormData({ ...formData, [key]: value })}
submitAttempted={submitAttempted}
isValid={isConfigValid()}
/>
<div className="mb-4" />
<ExtensionTimeoutField
timeout={formData.timeout || 300}
description={formData.description}
onChange={(key, value) => setFormData({ ...formData, [key]: value })}
submitAttempted={submitAttempted}
/>
</div>
{/* Divider */}
<hr className="border-t border-borderSubtle mb-4" />
{/* Divider */}
<hr className="border-t border-borderSubtle mb-4" />
{/* Environment Variables */}
<div className="mb-6">
<EnvVarsSection
envVars={formData.envVars}
onAdd={handleAddEnvVar}
onRemove={handleRemoveEnvVar}
onChange={handleEnvVarChange}
submitAttempted={submitAttempted}
/>
</div>
</>
{/* Command */}
<div className="mb-6">
<ExtensionConfigFields
type={formData.type}
full_cmd={formData.cmd || ''}
endpoint={formData.endpoint || ''}
onChange={(key, value) => setFormData({ ...formData, [key]: value })}
submitAttempted={submitAttempted}
isValid={isConfigValid()}
/>
<div className="mb-4" />
<ExtensionTimeoutField
timeout={formData.timeout || 300}
onChange={(key, value) => setFormData({ ...formData, [key]: value })}
submitAttempted={submitAttempted}
/>
</div>
{/* Divider */}
<hr className="border-t border-borderSubtle mb-4" />
{/* Environment Variables */}
<div className="mb-6">
<EnvVarsSection
envVars={formData.envVars}
onAdd={handleAddEnvVar}
onRemove={handleRemoveEnvVar}
onChange={handleEnvVarChange}
submitAttempted={submitAttempted}
onPendingInputChange={setHasPendingEnvVars}
/>
</div>
</>
)}
</Modal>
{/* Close Confirmation Modal */}
{showCloseConfirmation && (
<ConfirmationModal
isOpen={showCloseConfirmation}
title="Unsaved Changes"
message="You have unsaved changes to the extension configuration. Are you sure you want to close without saving?"
confirmLabel="Close Without Saving"
onConfirm={handleConfirmClose}
onCancel={handleCancelClose}
/>
)}
</Modal>
</>
);
}