Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ and this project adheres to

### Added

- Confirmation modal when changing adaptors in collaborative editor to warn
users that credentials will be reset
[#4395](https://github.com/OpenFn/lightning/issues/4395)

### Changed

- Editors can now provision and merge sandboxes; merge checks editor+ role on
Expand Down
129 changes: 123 additions & 6 deletions assets/js/collaborative-editor/components/ConfigureAdaptorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
DialogPanel,
DialogTitle,
} from '@headlessui/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import {
useCredentialQueries,
Expand All @@ -22,6 +22,7 @@ import {
import { cn } from '#/utils/cn';

import { AdaptorIcon } from './AdaptorIcon';
import { AlertDialog } from './AlertDialog';
import { Tooltip } from './Tooltip';
import { VersionPicker } from './VersionPicker';

Expand Down Expand Up @@ -185,11 +186,12 @@ function SectionDivider({ label }: SectionDividerProps) {
interface ConfigureAdaptorModalProps {
isOpen: boolean;
onClose: () => void;
onAdaptorChange: (adaptorPackage: string) => void; // Immediately sync adaptor to Y.Doc
onVersionChange: (version: string) => void; // Immediately sync version to Y.Doc
onAdaptorChange: (adaptorPackage: string) => void; // Update adaptor package in form state (onVersionChange will sync to Y.Doc)
onVersionChange: (version: string) => void; // Immediately sync complete package@version to Y.Doc
onCredentialChange: (credentialId: string | null) => void; // Immediately sync credential to Y.Doc
onOpenAdaptorPicker: () => void; // Notify parent to manage modal switching to adaptor picker
onOpenCredentialModal: (adaptorName: string, credentialId?: string) => void; // Notify parent to manage modal switching to credential modal (for create or edit)
pendingAdaptorSelection: string | null; // Adaptor selected from picker, awaiting confirmation
currentAdaptor: string;
currentVersion: string;
currentCredentialId: string | null;
Expand All @@ -206,12 +208,12 @@ interface ConfigureAdaptorModalProps {
export function ConfigureAdaptorModal({
isOpen,
onClose,
// Note: onAdaptorChange is in the interface for parent compatibility,
// but adaptor changes go through onOpenAdaptorPicker flow instead
onAdaptorChange,
onVersionChange,
onCredentialChange,
onOpenAdaptorPicker,
onOpenCredentialModal,
pendingAdaptorSelection,
currentAdaptor,
currentVersion,
currentCredentialId,
Expand All @@ -220,20 +222,27 @@ export function ConfigureAdaptorModal({
// UI state (not synced to Y.Doc)
const [showOtherCredentials, setShowOtherCredentials] = useState(false);

// Confirmation modal state
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const [pendingAdaptor, setPendingAdaptor] = useState<string | null>(null);

// Get current user and credentials from store
const currentUser = useUser();
const { projectCredentials, keychainCredentials } = useCredentials();
const { credentialExists, getCredentialId } = useCredentialQueries();

// High-priority Escape handler to prevent closing parent IDE/inspector
// Priority 100 (MODAL) ensures this runs before IDE handler (priority 50)
// When confirmation is open, disable this handler so AlertDialog's native Escape can close it
// After AlertDialog closes, Inspector's priority 10 handler fires and closes everything
// Result: Escape = "abort mission completely" (closes confirmation + modal + inspector)
useKeyboardShortcut(
'Escape',
() => {
onClose();
},
100,
{ enabled: isOpen }
{ enabled: isOpen && !isConfirmationModalOpen }
);

// When adaptor changes externally (from Y.Doc or adaptor picker),
Expand Down Expand Up @@ -443,6 +452,102 @@ export function ConfigureAdaptorModal({
onOpenAdaptorPicker(); // Let parent open AdaptorSelectionModal
};

/**
* Actually performs the adaptor change after confirmation (or if no
* confirmation needed).
*/
const handleAdaptorChangeConfirmed = useCallback(
(adaptorName: string) => {
// Extract package name and update adaptor
const packageMatch = adaptorName.match(/(.+?)(@|$)/);
const newPackage = packageMatch ? packageMatch[1] : adaptorName;

if (newPackage) {
onAdaptorChange(newPackage);

// Auto-select latest version for new adaptor, fallback to "latest" if not found
const adaptor = allAdaptors.find(a => a.name === newPackage);
let versionToUse = 'latest'; // Fallback

if (adaptor && adaptor.versions.length > 0) {
const sortedVersions = sortVersionsDescending(
adaptor.versions.map(v => v.version)
);
versionToUse = sortedVersions[0];
}

onVersionChange(versionToUse);
}

// Reopen ConfigureAdaptorModal (it was closed by handleChangeClick)
// The parent will handle reopening based on the adaptor change event
},
[allAdaptors, onAdaptorChange, onVersionChange]
);

/**
* Handles adaptor selection from AdaptorSelectionModal.
* Shows confirmation if adaptor package is changing and credentials are
* set.
*/
const handleAdaptorSelectFromPicker = useCallback(
(newAdaptorName: string) => {
const currentPackage = extractPackageName(currentAdaptor);
const newPackage = extractPackageName(newAdaptorName);

// Only show confirmation if:
// 1. Adaptor package is changing (not just version)
// 2. Credentials are currently set
const isAdaptorChanging = currentPackage !== newPackage;
const hasCredentials = currentCredentialId !== null;

if (isAdaptorChanging && hasCredentials) {
// Show confirmation modal
setPendingAdaptor(newAdaptorName);
setIsConfirmationModalOpen(true);
} else {
// No confirmation needed - proceed immediately
handleAdaptorChangeConfirmed(newAdaptorName);
}
},
[currentAdaptor, currentCredentialId, handleAdaptorChangeConfirmed]
);

/**
* Confirms adaptor change - clears credentials then changes adaptor.
*/
const handleAdaptorChangeConfirm = useCallback(() => {
if (pendingAdaptor) {
// Step 1: Clear credentials first (both project and keychain)
onCredentialChange(null);

// Step 2: Apply the adaptor change
handleAdaptorChangeConfirmed(pendingAdaptor);

// Step 3: Close confirmation modal and reset state
setPendingAdaptor(null);
setIsConfirmationModalOpen(false);
}
}, [pendingAdaptor, onCredentialChange, handleAdaptorChangeConfirmed]);

/**
* Cancels adaptor change - keeps current adaptor and credentials.
*/
const handleAdaptorChangeCancel = useCallback(() => {
// Simply discard the pending change
setPendingAdaptor(null);
setIsConfirmationModalOpen(false);
// ConfigureAdaptorModal remains open - user can try again or close
}, []);

// Handle pending adaptor selection from AdaptorSelectionModal
useEffect(() => {
if (!isOpen || !pendingAdaptorSelection) return;

// User selected a new adaptor from picker - check if confirmation needed
handleAdaptorSelectFromPicker(pendingAdaptorSelection);
}, [isOpen, pendingAdaptorSelection, handleAdaptorSelectFromPicker]);

// Open LiveView credential modal with adaptor schema (notifies parent)
const handleCreateCredential = () => {
const adaptorName = extractAdaptorName(currentAdaptor);
Expand Down Expand Up @@ -758,6 +863,18 @@ export function ConfigureAdaptorModal({
</div>
</div>
</Dialog>

{/* Confirmation modal for adaptor changes */}
<AlertDialog
isOpen={isConfirmationModalOpen}
onClose={handleAdaptorChangeCancel}
onConfirm={handleAdaptorChangeConfirm}
title="Change Adaptor?"
description="Warning: Changing adaptors will reset the credential for this step. Are you sure you want to continue?"
confirmLabel="Continue"
cancelLabel="Cancel"
variant="primary"
/>
</>
);
}
60 changes: 27 additions & 33 deletions assets/js/collaborative-editor/components/inspector/JobForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export function JobForm({ job }: JobFormProps) {
// Track if adaptor picker was opened from configure modal (to return there on close)
const [adaptorPickerFromConfigure, setAdaptorPickerFromConfigure] =
useState(false);
// Track adaptor selected from picker before confirmation
const [pendingAdaptorSelection, setPendingAdaptorSelection] = useState<
string | null
>(null);

// Credential modal is managed by the context
const { openCredentialModal, onModalClose, onCredentialSaved } =
Expand Down Expand Up @@ -194,43 +198,29 @@ export function JobForm({ job }: JobFormProps) {
);

// Handle adaptor selection from picker
const handleAdaptorSelect = useCallback(
(adaptorName: string) => {
// Update the adaptor package in form
const packageMatch = adaptorName.match(/(.+?)(@|$)/);
const newPackage = packageMatch ? packageMatch[1] : adaptorName;
form.setFieldValue('adaptor_package', newPackage || null);

// Set version to "latest" by default when picking an adaptor
const fullAdaptor = `${newPackage}@latest`;
form.setFieldValue('adaptor', fullAdaptor);

// Close adaptor picker and always open configure modal
setIsAdaptorPickerOpen(false);
setAdaptorPickerFromConfigure(false);
setIsConfigureModalOpen(true);
},
[form]
);
const handleAdaptorSelect = useCallback((adaptorName: string) => {
// Close adaptor picker
setIsAdaptorPickerOpen(false);

// Handler for adaptor changes - immediately syncs to Y.Doc
const handleAdaptorChange = useCallback(
(adaptorPackage: string) => {
// Get current version from form
const currentAdaptorValue = form.getFieldValue('adaptor') as string;
const { version: currentVersion } = resolveAdaptor(currentAdaptorValue);
// Store selection as pending (don't apply yet)
setPendingAdaptorSelection(adaptorName);

// Build new adaptor string with current version
const newAdaptor = `${adaptorPackage}@${currentVersion || 'latest'}`;
// Reopen ConfigureAdaptorModal which will handle confirmation
setIsConfigureModalOpen(true);
}, []);

// Update form state
// Handle confirmed adaptor change (after confirmation or no confirmation needed)
const handleAdaptorChangeConfirmed = useCallback(
(adaptorPackage: string) => {
// Update form state with new adaptor package
// Note: ConfigureAdaptorModal will call onVersionChange next, which will
// sync the complete package@version to Y.Doc in a single operation
form.setFieldValue('adaptor_package', adaptorPackage);
form.setFieldValue('adaptor', newAdaptor);

// Persist to Y.Doc
updateJob(job.id, { adaptor: newAdaptor });
// Clear pending selection
setPendingAdaptorSelection(null);
},
[form, job.id, updateJob]
[form]
);

// Handler for version changes - immediately syncs to Y.Doc
Expand Down Expand Up @@ -370,12 +360,16 @@ export function JobForm({ job }: JobFormProps) {
{/* Configure Adaptor Modal */}
<ConfigureAdaptorModal
isOpen={isConfigureModalOpen}
onClose={() => setIsConfigureModalOpen(false)}
onAdaptorChange={handleAdaptorChange}
onClose={() => {
setIsConfigureModalOpen(false);
setPendingAdaptorSelection(null); // Clear pending on close
}}
onAdaptorChange={handleAdaptorChangeConfirmed}
onVersionChange={handleVersionChange}
onCredentialChange={handleCredentialChange}
onOpenAdaptorPicker={handleOpenAdaptorPickerFromConfigure}
onOpenCredentialModal={handleOpenCredentialModal}
pendingAdaptorSelection={pendingAdaptorSelection}
currentAdaptor={
resolveAdaptor(currentAdaptor).package || '@openfn/language-common'
}
Expand Down
Loading