Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ and this project adheres to
[#3887](https://github.com/OpenFn/lightning/issues/3887)
- Mix task to merge project state files without database access
[#3615](https://github.com/OpenFn/lightning/issues/3615)
- Support Undo/Redo commands in the collab editor
[#3712](https://github.com/OpenFn/lightning/issues/3712)
- Added adaptor docs & metadata panel to IDE
[#3857](https://github.com/OpenFn/lightning/issues/3857)
- Show Error indication on workflow settings button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ type ChartCache = {
const LAYOUT_DURATION = 300;

// Simple React hook for Tippy tooltips that finds buttons by their content
const useTippyForControls = (isManualLayout: boolean) => {
const useTippyForControls = (
isManualLayout: boolean,
canUndo: boolean,
canRedo: boolean
) => {
useEffect(() => {
// Find the control buttons and initialize tooltips based on their dataset attributes
const buttons = document.querySelectorAll('.react-flow__controls button');
Expand All @@ -96,7 +100,7 @@ const useTippyForControls = (isManualLayout: boolean) => {
f();
});
};
}, [isManualLayout]); // Only run once on mount
}, [isManualLayout, canUndo, canRedo]);
};

const logger = _logger.ns('WorkflowDiagram').seal();
Expand Down Expand Up @@ -124,9 +128,14 @@ export default function WorkflowDiagram(props: WorkflowDiagramProps) {
updatePositions,
} = usePositions();

// TODO: implement these
const undo = useCallback(() => {}, []);
const redo = useCallback(() => {}, []);
// Undo/redo functions
const undo = useCallback(() => {
workflowStore.undo();
}, [workflowStore]);

const redo = useCallback(() => {
workflowStore.redo();
}, [workflowStore]);

const { jobs, triggers, edges } = useWorkflowState(state => ({
jobs: state.jobs,
Expand All @@ -152,6 +161,42 @@ export default function WorkflowDiagram(props: WorkflowDiagramProps) {
const [drawerWidth, setDrawerWidth] = useState(0);
const workflowDiagramRef = useRef<HTMLDivElement>(null);

// Undo/redo state
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);

const undoManager = workflowStore.getSnapshot().undoManager;

// Listen to UndoManager stack changes
useEffect(() => {
if (!undoManager) {
setCanUndo(false);
setCanRedo(false);
return;
}

const updateUndoRedoState = () => {
// Read directly from undoManager instead of calling store methods
// This avoids potential stale closures and is more reliable
setCanUndo(undoManager.undoStack.length > 0);
setCanRedo(undoManager.redoStack.length > 0);
};

// Initial state
updateUndoRedoState();

// Listen to stack changes
undoManager.on('stack-item-added', updateUndoRedoState);
undoManager.on('stack-item-popped', updateUndoRedoState);
undoManager.on('stack-cleared', updateUndoRedoState);

return () => {
undoManager.off('stack-item-added', updateUndoRedoState);
undoManager.off('stack-item-popped', updateUndoRedoState);
undoManager.off('stack-cleared', updateUndoRedoState);
};
}, [undoManager]);

// Modal state for adaptor selection
const [pendingPlaceholder, setPendingPlaceholder] = useState<{
sourceNode: Flow.Node;
Expand Down Expand Up @@ -764,7 +809,7 @@ export default function WorkflowDiagram(props: WorkflowDiagramProps) {
workflowStore
);
// Set up tooltips for control buttons
useTippyForControls(isManualLayout);
useTippyForControls(isManualLayout, canUndo, canRedo);

// undo/redo keyboard shortcuts
useEffect(() => {
Expand Down Expand Up @@ -845,10 +890,20 @@ export default function WorkflowDiagram(props: WorkflowDiagramProps) {
>
<span className="text-black hero-squares-2x2 w-4 h-4" />
</ControlButton>
<ControlButton onClick={() => undo()} data-tooltip="Undo">
<ControlButton
onClick={() => undo()}
data-tooltip={canUndo ? 'Undo' : 'Nothing to undo'}
data-testid="undo-button"
disabled={!canUndo}
>
<span className="text-black hero-arrow-uturn-left w-4 h-4" />
</ControlButton>
<ControlButton onClick={() => redo()} data-tooltip="Redo">
<ControlButton
onClick={() => redo()}
data-tooltip={canRedo ? 'Redo' : 'Nothing to redo'}
data-testid="redo-button"
disabled={!canRedo}
>
<span className="text-black hero-arrow-uturn-right w-4 h-4" />
</ControlButton>
</Controls>
Expand Down
82 changes: 79 additions & 3 deletions assets/js/collaborative-editor/stores/createWorkflowStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ function produceInitialState() {
edges: [],
positions: {},

// Initialize UndoManager
undoManager: null,

// Initialize UI state
selectedJobId: null,
selectedTriggerId: null,
Expand Down Expand Up @@ -268,7 +271,7 @@ export const createWorkflowStore = () => {
// Redux DevTools integration (development/test only)
const devtools = wrapStoreWithDevTools<Workflow.State>({
name: 'WorkflowStore',
excludeKeys: ['ydoc', 'provider'], // Exclude Y.Doc and provider (too large/circular)
excludeKeys: ['ydoc', 'provider', 'undoManager'], // Exclude Y.Doc, provider, and undoManager (too large/circular)
maxAge: 200, // Higher limit to prevent history loss from frequent updates
trace: false,
});
Expand Down Expand Up @@ -381,6 +384,19 @@ export const createWorkflowStore = () => {
const positionsMap = ydoc.getMap('positions');
const errorsMap = ydoc.getMap('errors'); // NEW: Get errors map

// Create UndoManager tracking all workflow collections
// NOTE: Job body Y.Text instances are intentionally NOT tracked here.
// Monaco Editor has its own undo/redo (Cmd+Z) that handles text editing.
// Including job bodies here would create conflicts and lead to jobs being
// deleted when undoing body edits.
const undoManager = new Y.UndoManager(
[workflowMap, jobsArray, triggersArray, edgesArray, positionsMap],
{
captureTimeout: 500, // Merge edits within 500ms
trackedOrigins: new Set([null]), // Track local changes only
Comment on lines +395 to +396
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we add comments to explain why these choices ? I think it would be valuable for maintainability.

}
);

// Set up observers
const workflowObserver = () => {
updateState(draft => {
Expand Down Expand Up @@ -573,6 +589,11 @@ export const createWorkflowStore = () => {
}
},
() => errorsMap.unobserveDeep(errorsObserver), // NEW: Cleanup function
() => {
// UndoManager cleanup
undoManager.clear();
undoManager.destroy();
},
];

state = produce(state, draft => {
Expand All @@ -587,6 +608,11 @@ export const createWorkflowStore = () => {
positionsObserver();
errorsObserver(); // NEW: Initial sync

// Update state with undoManager
updateState(draft => {
draft.undoManager = undoManager;
}, 'undoManager/initialized');

// Initialize DevTools connection
devtools.connect();

Expand All @@ -607,8 +633,10 @@ export const createWorkflowStore = () => {
// Disconnect DevTools
devtools.disconnect();

// Update collaboration status
updateState(_draft => {}, 'disconnected');
// Update collaboration status and reset undoManager
updateState(draft => {
draft.undoManager = null;
}, 'disconnected');
};

// =============================================================================
Expand Down Expand Up @@ -1399,6 +1427,46 @@ export const createWorkflowStore = () => {
}
};

// Undo/Redo Commands
// =============================================================================
// These commands trigger Y.Doc changes via UndoManager, which then flow
// through the normal observer pattern (Pattern 1)
//
// Note: UndoManager tracks local changes only (trackedOrigins: new Set([null])).
// Remote changes from other collaborators are NOT undoable by this client,
// since they represent other users' intentional actions.

const undo = () => {
const undoManager = state.undoManager;
if (undoManager && undoManager.undoStack.length > 0) {
undoManager.undo();
}
};

const redo = () => {
const undoManager = state.undoManager;
if (undoManager && undoManager.redoStack.length > 0) {
undoManager.redo();
}
};

const canUndo = (): boolean => {
const undoManager = state.undoManager;
return undoManager ? undoManager.undoStack.length > 0 : false;
};

const canRedo = (): boolean => {
const undoManager = state.undoManager;
return undoManager ? undoManager.redoStack.length > 0 : false;
};

const clearHistory = () => {
const undoManager = state.undoManager;
if (undoManager) {
undoManager.clear();
}
};

return {
// Core store interface
subscribe,
Expand Down Expand Up @@ -1455,6 +1523,14 @@ export const createWorkflowStore = () => {

// Trigger auth methods
requestTriggerAuthMethods,
// =============================================================================
// Undo/Redo Commands
// =============================================================================
undo,
redo,
canUndo,
canRedo,
clearHistory,
};
};

Expand Down
3 changes: 3 additions & 0 deletions assets/js/collaborative-editor/types/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export namespace Workflow {
edges: Workflow.Edge[]; // Has errors property
positions: Workflow.Positions;

// UndoManager for undo/redo operations
undoManager: Y.UndoManager | null;

// Local UI state
selectedJobId: string | null;
selectedTriggerId: string | null;
Expand Down
Loading