diff --git a/.gitignore b/.gitignore index decbf2c3f8..b7bcd12c8a 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,4 @@ assets/test-results .claude/settings.local.json test-results +assets/coverage \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c89d2ccb24..e6a5fb3df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ and this project adheres to ### Changed +- Always keep the Diagram mounted even when the IDE is present + [#3981](https://github.com/OpenFn/lightning/issues/3981) +- Dropped react-hotkeys-hook for custom priority based key handler hook + [#3981](https://github.com/OpenFn/lightning/issues/3981) - Simplified IDE by only letting users see the "Create a New Manual Run Panel" when an existing Run isn't already loaded. Cleaned up the Run panel. [#4006](https://github.com/OpenFn/lightning/issues/4006) diff --git a/assets/eslint.config.js b/assets/eslint.config.js index 79b00897fd..33b5766cf9 100644 --- a/assets/eslint.config.js +++ b/assets/eslint.config.js @@ -45,7 +45,7 @@ const javascriptFiles = [ ].map(ext => `**/*.${ext}`); const nodeFiles = allExtensions.map(ext => `*.${ext}`); const browserFiles = allExtensions.flatMap(ext => - ['js', 'vendor', 'dev-server'].map(dir => `${dir}/**/*.${ext}`) + ['js', 'vendor', 'dev-server', 'test'].map(dir => `${dir}/**/*.${ext}`) ); const reactFiles = [ ...jsxExtensions, diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx index 58653114e0..c9058b87a0 100644 --- a/assets/js/collaborative-editor/CollaborativeEditor.tsx +++ b/assets/js/collaborative-editor/CollaborativeEditor.tsx @@ -1,5 +1,4 @@ import { useMemo } from 'react'; -import { HotkeysProvider } from 'react-hotkeys-hook'; import { SocketProvider } from '../react/contexts/SocketProvider'; import { useURLState } from '../react/lib/use-url-state'; @@ -22,6 +21,7 @@ import { import { useIsRunPanelOpen } from './hooks/useUI'; import { useVersionSelect } from './hooks/useVersionSelect'; import { useWorkflowState } from './hooks/useWorkflow'; +import { KeyboardProvider } from './keyboard'; export interface CollaborativeEditorDataProps { 'data-workflow-id': string; @@ -139,19 +139,14 @@ function BreadcrumbContent({ ]); return ( - <> - {/* Only render Header for Canvas mode - IDE mode has its own Header in FullScreenIDE */} - {!isIDEOpen && ( -
- {breadcrumbElements} -
- )} - +
+ {breadcrumbElements} +
); } @@ -178,9 +173,9 @@ export const CollaborativeEditor: WithActionProps< }; return ( - +
@@ -229,6 +224,6 @@ export const CollaborativeEditor: WithActionProps<
-
+ ); }; diff --git a/assets/js/collaborative-editor/components/AdaptorSelectionModal.tsx b/assets/js/collaborative-editor/components/AdaptorSelectionModal.tsx index cbcc1e3f5b..9a48f87295 100644 --- a/assets/js/collaborative-editor/components/AdaptorSelectionModal.tsx +++ b/assets/js/collaborative-editor/components/AdaptorSelectionModal.tsx @@ -1,8 +1,6 @@ import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; import { useEffect, useMemo, useState } from 'react'; -import { useHotkeysContext } from 'react-hotkeys-hook'; -import { HOTKEY_SCOPES } from '../constants/hotkeys'; import { useAdaptors } from '../hooks/useAdaptors'; import type { Adaptor } from '../types/adaptor'; import { getAdaptorDisplayName } from '../utils/adaptorUtils'; @@ -31,24 +29,6 @@ export function AdaptorSelectionModal({ const [searchQuery, setSearchQuery] = useState(''); const [focusedIndex, setFocusedIndex] = useState(0); - // Keyboard scope management - const { enableScope, disableScope } = useHotkeysContext(); - - useEffect(() => { - if (isOpen) { - enableScope(HOTKEY_SCOPES.MODAL); - disableScope(HOTKEY_SCOPES.PANEL); - disableScope(HOTKEY_SCOPES.RUN_PANEL); - } else { - disableScope(HOTKEY_SCOPES.MODAL); - enableScope(HOTKEY_SCOPES.PANEL); - } - - return () => { - disableScope(HOTKEY_SCOPES.MODAL); - }; - }, [isOpen, enableScope, disableScope]); - // Reset state when modal closes useEffect(() => { if (!isOpen) { diff --git a/assets/js/collaborative-editor/components/AlertDialog.tsx b/assets/js/collaborative-editor/components/AlertDialog.tsx index b3b095abaa..743635e9f3 100644 --- a/assets/js/collaborative-editor/components/AlertDialog.tsx +++ b/assets/js/collaborative-editor/components/AlertDialog.tsx @@ -4,10 +4,6 @@ import { DialogPanel, DialogTitle, } from '@headlessui/react'; -import { useEffect } from 'react'; -import { useHotkeysContext } from 'react-hotkeys-hook'; - -import { HOTKEY_SCOPES } from '#/collaborative-editor/constants/hotkeys'; interface AlertDialogProps { isOpen: boolean; @@ -52,24 +48,6 @@ export function AlertDialog({ ? 'bg-red-600 hover:bg-red-500 focus-visible:outline-red-600' : 'bg-primary-600 hover:bg-primary-500 focus-visible:outline-primary-600'; - // Use HotkeysContext to control keyboard scope precedence - const { enableScope, disableScope } = useHotkeysContext(); - - useEffect(() => { - if (isOpen) { - enableScope(HOTKEY_SCOPES.MODAL); - disableScope(HOTKEY_SCOPES.PANEL); - disableScope(HOTKEY_SCOPES.RUN_PANEL); - } else { - disableScope(HOTKEY_SCOPES.MODAL); - enableScope(HOTKEY_SCOPES.PANEL); - } - - return () => { - disableScope(HOTKEY_SCOPES.MODAL); - }; - }, [isOpen, enableScope, disableScope]); - return ( { - const event = new KeyboardEvent('keydown', { - key: 'Enter', - code: 'Enter', - metaKey: true, - ctrlKey: true, - bubbles: true, - cancelable: true, - }); - document.dispatchEvent(event); - }); - - // Override Monaco's CMD+Shift+Enter to allow react-hotkeys-hook to handle it - editor.addCommand( - monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter, - () => { - const event = new KeyboardEvent('keydown', { - key: 'Enter', - code: 'Enter', - metaKey: true, - ctrlKey: true, - shiftKey: true, - bubbles: true, - cancelable: true, - }); - document.dispatchEvent(event); - } - ); + // Override Monaco shortcuts to allow KeyboardProvider to handle them + addKeyboardShortcutOverrides(editor, monaco); // Create initial binding if ytext and awareness are available if (ytext && awareness) { diff --git a/assets/js/collaborative-editor/components/ConfigureAdaptorModal.tsx b/assets/js/collaborative-editor/components/ConfigureAdaptorModal.tsx index 2e33481770..7451e7a14c 100644 --- a/assets/js/collaborative-editor/components/ConfigureAdaptorModal.tsx +++ b/assets/js/collaborative-editor/components/ConfigureAdaptorModal.tsx @@ -4,19 +4,17 @@ import { DialogPanel, DialogTitle, } from '@headlessui/react'; -import { useEffect, useMemo, useState, useRef } from 'react'; -import { useHotkeysContext } from 'react-hotkeys-hook'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import { HOTKEY_SCOPES } from '#/collaborative-editor/constants/hotkeys'; import { - useCredentials, useCredentialQueries, + useCredentials, } from '#/collaborative-editor/hooks/useCredentials'; import type { Adaptor } from '#/collaborative-editor/types/adaptor'; import type { CredentialWithType } from '#/collaborative-editor/types/credential'; import { - extractAdaptorName, extractAdaptorDisplayName, + extractAdaptorName, extractPackageName, } from '#/collaborative-editor/utils/adaptorUtils'; @@ -65,24 +63,6 @@ export function ConfigureAdaptorModal({ const { projectCredentials, keychainCredentials } = useCredentials(); const { credentialExists, getCredentialId } = useCredentialQueries(); - // Keyboard scope management - const { enableScope, disableScope } = useHotkeysContext(); - - useEffect(() => { - if (isOpen) { - enableScope(HOTKEY_SCOPES.MODAL); - disableScope(HOTKEY_SCOPES.PANEL); - disableScope(HOTKEY_SCOPES.RUN_PANEL); - } else { - disableScope(HOTKEY_SCOPES.MODAL); - enableScope(HOTKEY_SCOPES.PANEL); - } - - return () => { - disableScope(HOTKEY_SCOPES.MODAL); - }; - }, [isOpen, enableScope, disableScope]); - // When adaptor changes externally (from Y.Doc or adaptor picker), // automatically update to newest version and clear invalid credentials const prevAdaptorRef = useRef(currentAdaptor); diff --git a/assets/js/collaborative-editor/components/Cursors.tsx b/assets/js/collaborative-editor/components/Cursors.tsx index a55a1b7d98..b813463535 100644 --- a/assets/js/collaborative-editor/components/Cursors.tsx +++ b/assets/js/collaborative-editor/components/Cursors.tsx @@ -87,7 +87,7 @@ export function Cursors() { } return { __html: cursorStyles }; - }, [cursorsMap.size, ...Array.from(cursorsMap.keys())]); + }, [cursorsMap]); // Detect when a users cursor is near the top of the editor and flip the // position of the label to below their position. diff --git a/assets/js/collaborative-editor/components/GitHubSyncModal.tsx b/assets/js/collaborative-editor/components/GitHubSyncModal.tsx index 5344641b92..6c1aff9c2a 100644 --- a/assets/js/collaborative-editor/components/GitHubSyncModal.tsx +++ b/assets/js/collaborative-editor/components/GitHubSyncModal.tsx @@ -5,9 +5,7 @@ import { DialogTitle, } from '@headlessui/react'; import { useEffect, useId, useState } from 'react'; -import { useHotkeysContext } from 'react-hotkeys-hook'; -import { HOTKEY_SCOPES } from '../constants/hotkeys'; import { useProject, useProjectRepoConnection, @@ -27,7 +25,6 @@ import { GITHUB_BASE_URL } from '../utils/constants'; * - Textarea for commit message input * - Save & Sync button that triggers workflow save and GitHub sync * - Cancel button to close without syncing - * - Proper keyboard scope management * * @example * // Add to WorkflowEditor or Header component @@ -49,24 +46,6 @@ export function GitHubSyncModal() { const [commitMessage, setCommitMessage] = useState(''); const [isSaving, setIsSaving] = useState(false); - // Use HotkeysContext to control keyboard scope precedence - const { enableScope, disableScope } = useHotkeysContext(); - - useEffect(() => { - if (isOpen) { - enableScope(HOTKEY_SCOPES.MODAL); - disableScope(HOTKEY_SCOPES.PANEL); - disableScope(HOTKEY_SCOPES.RUN_PANEL); - } else { - disableScope(HOTKEY_SCOPES.MODAL); - enableScope(HOTKEY_SCOPES.PANEL); - } - - return () => { - disableScope(HOTKEY_SCOPES.MODAL); - }; - }, [isOpen, enableScope, disableScope]); - // Set default commit message when modal opens useEffect(() => { if (isOpen && user) { diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx index 1cc284d07b..580ba69c21 100644 --- a/assets/js/collaborative-editor/components/Header.tsx +++ b/assets/js/collaborative-editor/components/Header.tsx @@ -1,7 +1,6 @@ import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; import { XMarkIcon } from '@heroicons/react/24/outline'; import { useCallback, useMemo } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useURLState } from '../../react/lib/use-url-state'; import { buildClassicalEditorUrl } from '../../utils/editorUrlConversion'; @@ -20,6 +19,7 @@ import { useWorkflowSettingsErrors, useWorkflowState, } from '../hooks/useWorkflow'; +import { useKeyboardShortcut } from '../keyboard'; import { ActiveCollaborators } from './ActiveCollaborators'; import { AIButton } from './AIButton'; @@ -244,33 +244,22 @@ export function Header({ return ; // Shortcut applies }, [canRun, runTooltipMessage, isRunPanelOpen]); - useHotkeys( - 'ctrl+s,meta+s', - event => { - event.preventDefault(); - if (canSave) { - void saveWorkflow(); - } + useKeyboardShortcut( + 'Control+s, Meta+s', + () => { + void saveWorkflow(); }, - { - enabled: true, - enableOnFormTags: true, - }, - [saveWorkflow, canSave] + 0, + { enabled: canSave } ); - useHotkeys( - 'ctrl+shift+s,meta+shift+s', - event => { - event.preventDefault(); - if (canSave && repoConnection) { - openGitHubSyncModal(); - } - }, - { - enableOnFormTags: true, + useKeyboardShortcut( + 'Control+Shift+s, Meta+Shift+s', + () => { + openGitHubSyncModal(); }, - [openGitHubSyncModal, canSave, repoConnection] + 0, + { enabled: canSave && !!repoConnection } ); return ( diff --git a/assets/js/collaborative-editor/components/ManualRunPanel.tsx b/assets/js/collaborative-editor/components/ManualRunPanel.tsx index 80443b26f0..ecb928f064 100644 --- a/assets/js/collaborative-editor/components/ManualRunPanel.tsx +++ b/assets/js/collaborative-editor/components/ManualRunPanel.tsx @@ -4,7 +4,6 @@ import { QueueListIcon, } from '@heroicons/react/24/outline'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { cn } from '#/utils/cn'; import _logger from '#/utils/logger'; @@ -18,12 +17,12 @@ import ExistingView from '../../manual-run-panel/views/ExistingView'; import { useURLState } from '../../react/lib/use-url-state'; import type { Dataclip } from '../api/dataclips'; import * as dataclipApi from '../api/dataclips'; -import { HOTKEY_SCOPES } from '../constants/hotkeys'; import { RENDER_MODES, type RenderMode } from '../constants/panel'; import { useActiveRun, useFollowRun } from '../hooks/useHistory'; import { useRunRetry } from '../hooks/useRunRetry'; import { useRunRetryShortcuts } from '../hooks/useRunRetryShortcuts'; import { useCanRun } from '../hooks/useWorkflow'; +import { useKeyboardShortcut } from '../keyboard'; import type { Workflow } from '../types/workflow'; import { InspectorFooter } from './inspector/InspectorFooter'; @@ -408,17 +407,12 @@ export function ManualRunPanel({ [projectId] ); - useHotkeys( - 'escape', + useKeyboardShortcut( + 'Escape', () => { onClose(); }, - { - enabled: true, - scopes: [HOTKEY_SCOPES.RUN_PANEL], - enableOnFormTags: true, - }, - [onClose] + 25 // RUN_PANEL priority ); // Run/retry shortcuts (standalone mode only - embedded uses IDEHeader) @@ -428,8 +422,8 @@ export function ManualRunPanel({ canRun, isRunning: isSubmitting || runIsProcessing, isRetryable, + priority: 25, // RUN_PANEL priority enabled: renderMode === RENDER_MODES.STANDALONE, - scope: HOTKEY_SCOPES.RUN_PANEL, }); const content = edgeId ? ( diff --git a/assets/js/collaborative-editor/components/VersionDropdown.tsx b/assets/js/collaborative-editor/components/VersionDropdown.tsx index 244fa3c04d..d4eeb356d9 100644 --- a/assets/js/collaborative-editor/components/VersionDropdown.tsx +++ b/assets/js/collaborative-editor/components/VersionDropdown.tsx @@ -79,13 +79,6 @@ export function VersionDropdown({ // Fetch versions when dropdown opens useEffect(() => { - console.log('VersionDropdown effect:', { - isOpen, - versionsLength: versions.length, - hasChannel: !!channel, - channel, - }); - if (isOpen && channel) { // Only fetch if we don't already have versions OR if we're not already loading if (versions.length === 0 && !isLoading) { diff --git a/assets/js/collaborative-editor/components/WorkflowEditor.tsx b/assets/js/collaborative-editor/components/WorkflowEditor.tsx index 5030fa5bf7..a43d17bd6b 100644 --- a/assets/js/collaborative-editor/components/WorkflowEditor.tsx +++ b/assets/js/collaborative-editor/components/WorkflowEditor.tsx @@ -3,11 +3,9 @@ */ import { useEffect, useRef, useState } from 'react'; -import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook'; import { useURLState } from '../../react/lib/use-url-state'; import type { WorkflowState as YAMLWorkflowState } from '../../yaml/types'; -import { HOTKEY_SCOPES } from '../constants/hotkeys'; import { useIsNewWorkflow, useProject } from '../hooks/useSessionContext'; import { useIsRunPanelOpen, @@ -15,13 +13,12 @@ import { useUICommands, } from '../hooks/useUI'; import { - useCanRun, useNodeSelection, useWorkflowActions, useWorkflowState, useWorkflowStoreContext, } from '../hooks/useWorkflow'; -import { notifications } from '../lib/notifications'; +import { useKeyboardShortcut } from '../keyboard'; import { CollaborativeWorkflowDiagram } from './diagram/CollaborativeWorkflowDiagram'; import { FullScreenIDE } from './ide/FullScreenIDE'; @@ -52,16 +49,6 @@ export function WorkflowEditor({ const isSyncingRef = useRef(false); const isInitialMountRef = useRef(true); - const { enableScope, disableScope } = useHotkeysContext(); - - useEffect(() => { - if (isRunPanelOpen) { - enableScope(HOTKEY_SCOPES.RUN_PANEL); - } else { - disableScope(HOTKEY_SCOPES.RUN_PANEL); - } - }, [isRunPanelOpen, enableScope, disableScope]); - useEffect(() => { if (isSyncingRef.current) return; @@ -190,9 +177,6 @@ export function WorkflowEditor({ const [showLeftPanel, setShowLeftPanel] = useState(isNewWorkflow); - const { canRun: canOpenRunPanel, tooltipMessage: runDisabledReason } = - useCanRun(); - const workflow = useWorkflowState(state => ({ ...state.workflow!, jobs: state.jobs, @@ -251,30 +235,26 @@ export function WorkflowEditor({ updateSearchParams({ panel: null }); }; - useHotkeys( - 'ctrl+e,meta+e', - event => { - event.preventDefault(); - + // Open Code Editor with Cmd+E / Ctrl+E + useKeyboardShortcut( + 'Control+e, Meta+e', + () => { if (currentNode.type !== 'job' || !currentNode.node) { return; } updateSearchParams({ panel: 'editor' }); }, + 0, // GLOBAL priority { enabled: !isIDEOpen, - enableOnFormTags: true, - }, - [currentNode, isIDEOpen, updateSearchParams] + } ); // CMD+Enter: Open run panel or run workflow - useHotkeys( - 'mod+enter', - event => { - event.preventDefault(); - + useKeyboardShortcut( + 'Control+Enter, Meta+Enter', + () => { // If run panel is already open, let the ManualRunPanel handle it if (isRunPanelOpen) { return; @@ -293,69 +273,63 @@ export function WorkflowEditor({ } } }, + 0, // GLOBAL priority { enabled: !isIDEOpen && !isRunPanelOpen, - enableOnFormTags: true, - }, - [currentNode, isIDEOpen, isRunPanelOpen, openRunPanel, workflow.triggers] + } ); return ( -
- {!isIDEOpen && ( - <> +
+
+ + + {!isRunPanelOpen && (
- - - {!isRunPanelOpen && ( -
- -
- )} - - {isRunPanelOpen && runPanelContext && projectId && workflowId && ( -
- - - -
- )} +
- - - - )} + )} + + {isRunPanelOpen && runPanelContext && projectId && workflowId && ( +
+ + + +
+ )} +
+ + {isIDEOpen && selectedJobId && ( (null); const [followRunId, setFollowRunId] = useState(null); - const [selectedDataclipState, setSelectedDataclipState] = useState(null); + const [selectedDataclipState, setSelectedDataclipState] = + useState(null); const [selectedTab, setSelectedTab] = useState< 'empty' | 'custom' | 'existing' >('empty'); @@ -175,7 +175,7 @@ export function FullScreenIDE({ const [manuallyUnselectedDataclip, setManuallyUnselectedDataclip] = useState(false); - const handleDataclipChange = useCallback((dataclip: any) => { + const handleDataclipChange = useCallback((dataclip: Dataclip | null) => { setSelectedDataclipState(dataclip); setManuallyUnselectedDataclip(dataclip === null); }, []); @@ -341,8 +341,7 @@ export function FullScreenIDE({ jobMatchesRun, isRunning: isSubmitting || runIsProcessing, isRetryable, - scope: HOTKEY_SCOPES.IDE, - enableOnContentEditable: true, + priority: 50, // IDE priority }); // Get data for Header @@ -370,15 +369,6 @@ export function FullScreenIDE({ onClose(); }, [onClose]); - const { enableScope, disableScope } = useHotkeysContext(); - - useEffect(() => { - enableScope(HOTKEY_SCOPES.IDE); - return () => { - disableScope(HOTKEY_SCOPES.IDE); - }; - }, [enableScope, disableScope]); - useEffect(() => { if (jobIdFromURL) { selectJob(jobIdFromURL); @@ -555,26 +545,23 @@ export function FullScreenIDE({ return cleanup; }, [handleEvent, currentJob, updateJob, requestCredentials]); - useHotkeys( - 'escape', - event => { + useKeyboardShortcut( + 'Escape', + () => { const activeElement = document.activeElement; const isMonacoFocused = activeElement?.closest('.monaco-editor'); if (isMonacoFocused) { (activeElement as HTMLElement).blur(); - event.preventDefault(); } else { onClose(); } }, + 50, // IDE priority { enabled: !isConfigureModalOpen && !isAdaptorPickerOpen && !isCredentialModalOpen, - scopes: [HOTKEY_SCOPES.IDE], - enableOnFormTags: true, - }, - [onClose, isConfigureModalOpen, isAdaptorPickerOpen, isCredentialModalOpen] + } ); // Save docs panel collapsed state to localStorage @@ -596,7 +583,7 @@ export function FullScreenIDE({ if (isLoading) { return (
@@ -678,7 +665,7 @@ export function FullScreenIDE({ ) : null; return ( -
+
{ - if (isCredentialModalOpen) { - disableScope(HOTKEY_SCOPES.PANEL); - } else { - enableScope(HOTKEY_SCOPES.PANEL); - } - }, [isCredentialModalOpen, enableScope, disableScope]); - // Parse initial adaptor value const initialAdaptor = job.adaptor || '@openfn/language-common@latest'; const { package: initialAdaptorPackage } = resolveAdaptor(initialAdaptor); diff --git a/assets/js/collaborative-editor/components/inspector/WebhookAuthMethodModal.tsx b/assets/js/collaborative-editor/components/inspector/WebhookAuthMethodModal.tsx index c0c0102022..28aaef281f 100644 --- a/assets/js/collaborative-editor/components/inspector/WebhookAuthMethodModal.tsx +++ b/assets/js/collaborative-editor/components/inspector/WebhookAuthMethodModal.tsx @@ -4,12 +4,10 @@ import { DialogPanel, DialogTitle, } from '@headlessui/react'; -import { useEffect, useState } from 'react'; -import { useHotkeysContext } from 'react-hotkeys-hook'; +import { useState } from 'react'; import { cn } from '#/utils/cn'; -import { HOTKEY_SCOPES } from '../../constants/hotkeys'; import { useLiveViewActions } from '../../contexts/LiveViewActionsContext'; import type { WebhookAuthMethod } from '../../types/sessionContext'; import type { Workflow } from '../../types/workflow'; @@ -42,21 +40,8 @@ export function WebhookAuthMethodModal({ const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); - // Keyboard scope management - const { enableScope, disableScope } = useHotkeysContext(); const { pushEvent } = useLiveViewActions(); - useEffect(() => { - enableScope(HOTKEY_SCOPES.MODAL); - disableScope(HOTKEY_SCOPES.PANEL); - disableScope(HOTKEY_SCOPES.RUN_PANEL); - - return () => { - disableScope(HOTKEY_SCOPES.MODAL); - enableScope(HOTKEY_SCOPES.PANEL); - }; - }, [enableScope, disableScope]); - const handleToggle = (methodId: string) => { setSelections(prev => ({ ...prev, diff --git a/assets/js/collaborative-editor/components/inspector/index.tsx b/assets/js/collaborative-editor/components/inspector/index.tsx index 18612f3a61..8ac85f9d34 100644 --- a/assets/js/collaborative-editor/components/inspector/index.tsx +++ b/assets/js/collaborative-editor/components/inspector/index.tsx @@ -3,10 +3,8 @@ * Shows details for jobs, triggers, and edges when selected */ -import { useHotkeys } from 'react-hotkeys-hook'; - import { useURLState } from '../../../react/lib/use-url-state'; -import { HOTKEY_SCOPES } from '../../constants/hotkeys'; +import { useKeyboardShortcut } from '../../keyboard'; import type { Workflow } from '../../types/workflow'; import { CodeViewPanel } from './CodeViewPanel'; @@ -29,14 +27,12 @@ interface InspectorProps { }; onClose: () => void; onOpenRunPanel: (context: { jobId?: string; triggerId?: string }) => void; - respondToHotKey: boolean; } export function Inspector({ currentNode, onClose, onOpenRunPanel, - respondToHotKey, }: InspectorProps) { const { searchParams, updateSearchParams } = useURLState(); @@ -63,17 +59,12 @@ export function Inspector({ } }; - useHotkeys( - 'escape', + useKeyboardShortcut( + 'Escape', () => { handleClose(); }, - { - enabled: respondToHotKey, - scopes: [HOTKEY_SCOPES.PANEL], - enableOnFormTags: true, // Allow Escape even in form fields - }, - [handleClose] + 10 // PANEL priority ); // Don't render if no mode selected diff --git a/assets/js/collaborative-editor/hooks/useAwareness.ts b/assets/js/collaborative-editor/hooks/useAwareness.ts index 9ecdfb204a..d99498e43d 100644 --- a/assets/js/collaborative-editor/hooks/useAwareness.ts +++ b/assets/js/collaborative-editor/hooks/useAwareness.ts @@ -156,23 +156,12 @@ export const useUserById = (userId: string | null): AwarenessUser | null => { }; /** - * Hook to get cursor data optimized for rendering - * Returns a Map for efficient lookups by clientId + * Hook to get the map of user cursors */ export const useUserCursors = (): Map => { const awarenessStore = useAwarenessStore(); - const selectCursors = awarenessStore.withSelector(state => { - const cursorsMap = new Map(); - - state.users.forEach(user => { - if (user.cursor || user.selection) { - cursorsMap.set(user.clientId, user); - } - }); - - return cursorsMap; - }); + const selectCursors = awarenessStore.withSelector(state => state.cursorsMap); return useSyncExternalStore(awarenessStore.subscribe, selectCursors); }; diff --git a/assets/js/collaborative-editor/hooks/useRunRetryShortcuts.ts b/assets/js/collaborative-editor/hooks/useRunRetryShortcuts.ts index d30704e8da..11e52887c6 100644 --- a/assets/js/collaborative-editor/hooks/useRunRetryShortcuts.ts +++ b/assets/js/collaborative-editor/hooks/useRunRetryShortcuts.ts @@ -1,4 +1,4 @@ -import { useHotkeys } from 'react-hotkeys-hook'; +import { useKeyboardShortcut } from '#/collaborative-editor/keyboard'; /** * Options for useRunRetryShortcuts hook @@ -30,32 +30,27 @@ export interface UseRunRetryShortcutsOptions { isRetryable: boolean; /** - * Whether the shortcuts should be enabled - * @default true + * Priority level for the keyboard shortcuts + * Higher priority = executes first when multiple handlers registered + * + * Recommended values: + * - 100: MODAL priority + * - 50: IDE priority + * - 25: RUN_PANEL priority + * - 10: PANEL priority + * - 0: GLOBAL priority */ - enabled?: boolean; + priority: number; /** - * Hotkeys scope (e.g., "ide" for fullscreen IDE) - * If not provided, shortcuts work globally - */ - scope?: string; - - /** - * Whether to enable shortcuts on form elements (input, textarea, select) + * Whether the shortcuts should be enabled * @default true */ - enableOnFormTags?: boolean; - - /** - * Whether to enable shortcuts on contenteditable elements (Monaco editor) - * @default false for non-IDE contexts, true for IDE - */ - enableOnContentEditable?: boolean; + enabled?: boolean; } /** - * Custom hook for run/retry keyboard shortcuts + * Custom hook for run/retry keyboard shortcuts using KeyboardProvider * * Provides two keyboard shortcuts: * - **Cmd/Ctrl+Enter**: Run (new work order) OR Retry (same input) @@ -66,28 +61,29 @@ export interface UseRunRetryShortcutsOptions { * - Preventing default browser behavior * - Checking if action can be executed (canRun, isRunning) * - Switching between run and retry based on context + * - Works in form fields and Monaco editor by default * * @example * ```tsx - * // In ManualRunPanel (standalone mode) + * // In ManualRunPanel (standalone mode, priority 25) * useRunRetryShortcuts({ * onRun: handleRun, * onRetry: handleRetry, * canRun, * isRunning: isSubmitting || runIsProcessing, * isRetryable, + * priority: 25, // RUN_PANEL priority * enabled: renderMode === RENDER_MODES.STANDALONE, * }); * - * // In IDEHeader (fullscreen IDE) + * // In FullScreenIDE (priority 50) * useRunRetryShortcuts({ * onRun: handleRun, * onRetry: handleRetry, * canRun, * isRunning: isSubmitting, * isRetryable, - * scope: "ide", - * enableOnContentEditable: true, + * priority: 50, // IDE priority * }); * ``` */ @@ -97,18 +93,13 @@ export function useRunRetryShortcuts({ canRun, isRunning, isRetryable, + priority, enabled = true, - scope, - enableOnFormTags = true, - enableOnContentEditable = false, }: UseRunRetryShortcutsOptions): void { // Cmd/Ctrl+Enter: Run or Retry based on state - useHotkeys( - 'mod+enter', - e => { - e.preventDefault(); - e.stopPropagation(); // Prevent parent handlers from firing - + useKeyboardShortcut( + 'Control+Enter, Meta+Enter', + () => { if (canRun && !isRunning) { if (isRetryable) { onRetry(); @@ -117,31 +108,20 @@ export function useRunRetryShortcuts({ } } }, - { - enabled, - scopes: scope ? [scope] : [], - enableOnFormTags, - enableOnContentEditable, - } + priority, + { enabled } ); // Cmd/Ctrl+Shift+Enter: Force new work order - useHotkeys( - 'mod+shift+enter', - e => { - e.preventDefault(); - e.stopPropagation(); - + useKeyboardShortcut( + 'Control+Shift+Enter, Meta+Shift+Enter', + () => { if (canRun && !isRunning && isRetryable) { // Force new work order even in retry mode onRun(); } }, - { - enabled, - scopes: scope ? [scope] : [], - enableOnFormTags, - enableOnContentEditable, - } + priority, + { enabled } ); } diff --git a/assets/js/collaborative-editor/keyboard/Example.tsx b/assets/js/collaborative-editor/keyboard/Example.tsx new file mode 100644 index 0000000000..70648abb8b --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/Example.tsx @@ -0,0 +1,161 @@ +/** + * Example component demonstrating all keyboard shortcut features + * + * This is for documentation purposes only. Not used in production. + */ + +import { useState } from 'react'; + +import { KeyboardProvider, useKeyboardShortcut } from './index'; + +// Define priorities for this example (applications should define their own) +const PRIORITY = { + MODAL: 100, + IDE: 50, + PANEL: 10, + DEFAULT: 0, +}; + +function ModalExample() { + const [isOpen, setIsOpen] = useState(false); + + useKeyboardShortcut( + 'Escape', + () => { + console.log('Modal: Closing modal'); + setIsOpen(false); + }, + PRIORITY.MODAL, + { + enabled: isOpen, // Only active when modal is open + } + ); + + return ( +
+ + {isOpen && ( +
+

Modal (Priority: 100)

+

Press ESC to close

+
+ )} +
+ ); +} + +function IDEExample() { + const [monacoHasFocus, setMonacoHasFocus] = useState(false); + + useKeyboardShortcut( + 'Escape', + () => { + if (monacoHasFocus) { + console.log('IDE: Blurring Monaco, passing to next handler'); + setMonacoHasFocus(false); + return false; // Pass to next handler + } + console.log('IDE: Closing IDE'); + return undefined; + }, + PRIORITY.IDE + ); + + return ( +
+

IDE (Priority: 50)

+

Press ESC to close (or blur Monaco if focused)

+ setMonacoHasFocus(true)} + onBlur={() => setMonacoHasFocus(false)} + /> + {monacoHasFocus && ( +

Monaco focused - ESC will blur and pass to next handler

+ )} +
+ ); +} + +function PanelExample() { + const [isOpen, setIsOpen] = useState(true); + + useKeyboardShortcut( + 'Escape', + () => { + console.log('Panel: Closing panel'); + setIsOpen(false); + }, + PRIORITY.PANEL + ); + + if (!isOpen) + return ; + + return ( +
+

Panel (Priority: 10)

+

Press ESC to close

+
+ ); +} + +function MultiComboExample() { + const [log, setLog] = useState([]); + + useKeyboardShortcut( + 'Cmd+Enter, Ctrl+Enter', + () => { + const msg = 'Cmd/Ctrl+Enter pressed'; + console.log(msg); + setLog(prev => [...prev, msg]); + }, + PRIORITY.DEFAULT + ); + + return ( +
+

Multi-Combo Example

+

Press Cmd+Enter or Ctrl+Enter

+
    + {log.map((entry, i) => ( +
  • {entry}
  • + ))} +
+
+ ); +} + +export function KeyboardExample() { + return ( + +
+

Keyboard Shortcuts Example

+

Open browser console to see logs

+ + + + + + +
+

Priority Order (ESC key):

+
    +
  1. Modal (100) - Only when modal is open
  2. +
  3. IDE (50) - Returns false if Monaco focused
  4. +
  5. Panel (10) - Runs if IDE returns false or isn't registered
  6. +
+
+
+
+ ); +} diff --git a/assets/js/collaborative-editor/keyboard/KeyboardProvider.tsx b/assets/js/collaborative-editor/keyboard/KeyboardProvider.tsx new file mode 100644 index 0000000000..cbebaa8b98 --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/KeyboardProvider.tsx @@ -0,0 +1,333 @@ +/** + * Priority-based keyboard shortcut system using tinykeys + * + * This module provides a centralized keyboard handling system where multiple + * components can register handlers for the same key combination with explicit + * priorities. The system ensures the highest priority handler executes, with + * a fallback mechanism if a handler returns false. + * + * Features: + * - Explicit priority-based handler selection + * - Automatic preventDefault and stopPropagation (configurable) + * - Always works in form fields (no configuration needed) + * - Efficient: only one tinykeys listener per key combo + * - Return false to pass to next handler + * - Enable/disable handlers without unmounting + * + * Usage: + * ```tsx + * + * + * + * + * // In component: + * useKeyboardShortcut("Escape", () => { + * console.log("Escape pressed"); + * }, 10); // Priority number + * ``` + */ + +import { + createContext, + useContext, + useRef, + useEffect, + useCallback, + useMemo, + type ReactNode, +} from 'react'; +import { tinykeys } from 'tinykeys'; + +import type { + Handler, + KeyboardContextValue, + KeyboardHandlerOptions, + KeyboardHandlerCallback, + KeyboardDebugInfo, + HandlerDebugInfo, +} from './types'; + +const KeyboardContext = createContext(null); + +export interface KeyboardProviderProps { + children: ReactNode; +} + +export function KeyboardProvider({ children }: KeyboardProviderProps) { + // Registry maps key combos to handler arrays + const registry = useRef(new Map()); + + // Unsubscribers for tinykeys listeners + const unsubscribers = useRef(new Map void>()); + + /** + * Register a keyboard handler + */ + const register = useCallback( + ( + combos: string, + handler: Omit & { + options?: KeyboardHandlerOptions; + } + ): (() => void) => { + // Create full handler with defaults + const fullHandler: Handler = { + ...handler, + id: Math.random().toString(36).substring(7), + registeredAt: Date.now(), + options: { + preventDefault: handler.options?.preventDefault ?? true, + stopPropagation: handler.options?.stopPropagation ?? true, + enabled: handler.options?.enabled ?? true, + }, + }; + + // Split combo string into individual combos + const comboList = combos.split(',').map(c => c.trim()); + + comboList.forEach(combo => { + const existing = registry.current.get(combo) || []; + registry.current.set(combo, [...existing, fullHandler]); + + // Only bind tinykeys if this is the first handler for this combo + if (existing.length === 0) { + const unsubscribe = tinykeys(window, { + [combo]: (event: KeyboardEvent) => { + const handlers = registry.current.get(combo); + if (!handlers || handlers.length === 0) return; + + // Sort by priority (desc), then by registeredAt (desc) + const sorted = [...handlers] + .filter(h => h.options.enabled) // Only enabled handlers + .sort((a, b) => { + if (b.priority !== a.priority) { + return b.priority - a.priority; + } + return b.registeredAt - a.registeredAt; + }); + + // Try handlers in priority order + for (const handler of sorted) { + try { + const result = handler.callback(event); + + // If handler didn't return false, it claimed the event + if (result !== false) { + if (handler.options.preventDefault) { + event.preventDefault(); + } + if (handler.options.stopPropagation) { + event.stopPropagation(); + } + break; // Stop trying handlers + } + // result === false: try next handler + } catch (error: unknown) { + console.error( + `[KeyboardProvider] Error in handler for "${combo}":`, + error + ); + // Re-throw immediately - errors should not fallback to other handlers + // Only "return false" should trigger fallback behavior + throw error; + } + } + }, + }); + + unsubscribers.current.set(combo, unsubscribe); + } + }); + + // Return cleanup function + return () => { + comboList.forEach(combo => { + const current = registry.current.get(combo) || []; + const filtered = current.filter(h => h.id !== fullHandler.id); + + if (filtered.length === 0) { + // Last handler for this combo - unbind tinykeys + registry.current.delete(combo); + const unsubscribe = unsubscribers.current.get(combo); + unsubscribe?.(); + unsubscribers.current.delete(combo); + } else { + // Still handlers left, just update registry + registry.current.set(combo, filtered); + } + }); + }; + }, + [] + ); + + /** + * Get debug information about all registered handlers + */ + const getDebugInfo = useCallback((): KeyboardDebugInfo => { + const handlers = new Map(); + let handlerCount = 0; + + registry.current.forEach((handlerList, combo) => { + const debugInfoList = handlerList.map(handler => ({ + id: handler.id, + priority: handler.priority, + enabled: handler.options.enabled, + registeredAt: handler.registeredAt, + preventDefault: handler.options.preventDefault, + stopPropagation: handler.options.stopPropagation, + })); + + // Sort by priority (desc), then by registeredAt (desc) to match execution order + debugInfoList.sort((a, b) => { + if (b.priority !== a.priority) { + return b.priority - a.priority; + } + return b.registeredAt - a.registeredAt; + }); + + handlers.set(combo, debugInfoList); + handlerCount += handlerList.length; + }); + + return { + handlers, + comboCount: handlers.size, + handlerCount, + }; + }, []); + + // Cleanup all on unmount + useEffect(() => { + const unsubs = unsubscribers.current; + const reg = registry.current; + return () => { + unsubs.forEach(unsubscribe => unsubscribe()); + unsubs.clear(); + reg.clear(); + }; + }, []); + + return ( + + {children} + + ); +} + +/** + * Hook to register keyboard shortcuts + * + * @param combos - Comma-separated key combinations + * (e.g., "Escape", "Cmd+Enter, Ctrl+Enter") + * @param callback - Handler function (return false to pass to next handler) + * @param priority - Handler priority (higher = executes first) + * @param options - Additional configuration + * + * @example + * ```tsx + * // Basic usage + * useKeyboardShortcut("Escape", () => { + * closeModal(); + * }, 100); // High priority + * + * // With options + * useKeyboardShortcut("Enter", () => { + * submitForm(); + * }, 0, { // Default priority + * preventDefault: false, // Don't prevent default + * }); + * + * // Return false to pass to next handler + * useKeyboardShortcut("Escape", (e) => { + * if (monacoHasFocus) { + * monacoRef.current.blur(); + * return false; // Let next handler run + * } + * closeEditor(); + * }, 50); // IDE priority + * ``` + */ +export function useKeyboardShortcut( + combos: string, + callback: KeyboardHandlerCallback, + priority: number = 0, + options?: KeyboardHandlerOptions +) { + const context = useContext(KeyboardContext); + + if (!context) { + throw new Error('useKeyboardShortcut must be used within KeyboardProvider'); + } + + const { register } = context; + + // Stable callback ref to avoid re-registering on every render + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + // Stable options ref + const optionsRef = useRef(options); + useEffect(() => { + optionsRef.current = options; + }, [options]); + + // Serialize options for shallow comparison + const serializedOptions = useMemo(() => JSON.stringify(options), [options]); + + useEffect(() => { + return register( + combos, + optionsRef.current === undefined + ? { + callback: event => callbackRef.current(event), + priority, + } + : { + callback: event => callbackRef.current(event), + priority, + options: optionsRef.current, + } + ); + }, [combos, priority, serializedOptions, register]); +} + +/** + * Hook to get debug information about registered keyboard shortcuts + * + * This hook is useful for debugging and testing to inspect what shortcuts + * are currently registered, their priorities, and their enabled status. + * + * @returns Debug information including all registered handlers and stats + * + * @example + * ```tsx + * function DebugPanel() { + * const debugInfo = useKeyboardDebugInfo(); + * + * console.log(`Total combos: ${debugInfo.comboCount}`); + * console.log(`Total handlers: ${debugInfo.handlerCount}`); + * + * debugInfo.handlers.forEach((handlers, combo) => { + * console.log(`${combo}:`, handlers); + * }); + * + * return
See console for keyboard shortcuts
; + * } + * ``` + */ +export function useKeyboardDebugInfo(): KeyboardDebugInfo { + const context = useContext(KeyboardContext); + + if (!context) { + throw new Error( + 'useKeyboardDebugInfo must be used within KeyboardProvider' + ); + } + + const { getDebugInfo } = context; + + return getDebugInfo(); +} diff --git a/assets/js/collaborative-editor/keyboard/README.md b/assets/js/collaborative-editor/keyboard/README.md new file mode 100644 index 0000000000..4a76c9bccc --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/README.md @@ -0,0 +1,278 @@ +# Priority-Based Keyboard Shortcuts + +A centralized keyboard handling system for the collaborative editor that +provides explicit priority-based handler selection, preventing conflicts and +simplifying keyboard logic. + +## Features + +- **Explicit priorities**: No more guessing which handler will fire +- **Return false pattern**: Handler can pass control to next handler +- **Smart defaults**: Always works in form fields, prevents default browser + behavior +- **Efficient**: Only one tinykeys listener per key combo +- **Type-safe**: Full TypeScript support +- **Enable/disable**: Control handlers without unmounting + +## Basic Usage + +### 1. Wrap your app with KeyboardProvider + +```tsx +import { KeyboardProvider } from '#/collaborative-editor/keyboard'; + +function CollaborativeEditor() { + return {/* Your app components */}; +} +``` + +### 2. Register keyboard shortcuts in components + +```tsx +import { useKeyboardShortcut } from '#/collaborative-editor/keyboard'; + +function Inspector() { + const handleEscape = () => { + closeInspector(); + }; + + // Priority is just a number - higher numbers execute first + useKeyboardShortcut('Escape', handleEscape, 10); + + return
Inspector content
; +} +``` + +## Priority System + +Priority is just a number: + +- **Higher number = higher priority** (executes first) +- **Same priority = most recently mounted component wins** +- **Disabled handlers are skipped** + +**Suggested approach**: Define constants in your application: + +```typescript +const PRIORITY = { + MODAL: 100, // Highest priority + IDE: 50, // Full-screen IDE + RUN_PANEL: 25, // Manual run panel + PANEL: 10, // Inspector panel + DEFAULT: 0, // Base level +}; + +// Then use: +useKeyboardShortcut('Escape', handler, PRIORITY.MODAL); +``` + +## Advanced Patterns + +### Return False to Pass Control + +A handler can return `false` to pass the event to the next handler in priority +order: + +```tsx +// FullScreenIDE.tsx (higher priority) +useKeyboardShortcut( + 'Escape', + e => { + if (monacoRef.current?.hasTextFocus()) { + monacoRef.current.blur(); + return false; // Let Inspector's ESC handler run if it wants + } + closeEditor(); + // Implicit return undefined = we handled it + }, + 50 +); // IDE priority + +// Inspector.tsx (lower priority) +useKeyboardShortcut( + 'Escape', + () => { + closeInspector(); // Will run if IDE returns false + }, + 10 +); // Panel priority +``` + +### Error Handling + +**Errors stop the handler chain immediately and propagate to React:** + +```tsx +useKeyboardShortcut( + 'Cmd+s', + () => { + if (!canSave) { + throw new Error('Cannot save in current state'); + } + saveDocument(); + }, + 10 +); +``` + +**Key behaviors:** + +- Errors are logged to console with context (for debugging) +- Errors are re-thrown to React error boundaries and monitoring tools +- **Lower-priority handlers do NOT run** when an error is thrown +- Only `return false` triggers fallback to next handler +- Use errors for exceptional cases, `return false` for intentional pass-through + +### Enable/Disable Without Unmounting + +Control whether a handler is active using the `enabled` option: + +```tsx +function FullScreenIDE({ isOpen }) { + useKeyboardShortcut( + 'Escape', + () => { + onClose(); + }, + 50, + { + // IDE priority + enabled: isOpen, // Only respond when IDE is open + } + ); +} +``` + +### Multiple Key Combos + +Register multiple key combinations for the same handler: + +```tsx +useKeyboardShortcut( + 'Cmd+Enter, Ctrl+Enter', + () => { + submitForm(); + }, + 0 +); // Default priority +``` + +### Customize Behavior + +```tsx +useKeyboardShortcut( + 'Enter', + () => { + submitForm(); + }, + 0, + { + // Default priority + preventDefault: false, // Don't prevent default behavior + stopPropagation: false, // Allow event to bubble + enabled: canSubmit, // Conditional activation + } +); +``` + +## Options Reference + +```typescript +interface KeyboardHandlerOptions { + /** + * Prevent default browser behavior + * @default true + */ + preventDefault?: boolean; + + /** + * Stop event propagation after handler executes + * @default true + */ + stopPropagation?: boolean; + + /** + * Enable/disable handler without unmounting + * @default true + */ + enabled?: boolean; +} +``` + +## Key Combo Syntax + +The system uses [tinykeys](https://github.com/jamiebuilds/tinykeys) for key +combo parsing. Common patterns: + +- Single keys: `"Escape"`, `"Enter"`, `"a"` +- Modifiers: `"Cmd+s"`, `"Ctrl+Enter"`, `"Shift+Alt+k"` +- Multiple combos: `"Cmd+Enter, Ctrl+Enter"` (comma-separated) +- Case-insensitive: `"cmd+s"` and `"Cmd+S"` are equivalent + +**Platform Modifiers:** + +- `Cmd` = ⌘ on Mac, Windows key on Windows +- `Ctrl` = Control on all platforms +- `Shift` = Shift on all platforms +- `Alt` = Option on Mac, Alt on Windows + +## Comparison with react-hotkeys-hook + +| Feature | react-hotkeys-hook | Priority System | +| -------------------- | ------------------------ | ------------------------------ | +| Priority control | Scope-based (implicit) | Number-based (explicit) | +| Handler selection | Last registered in scope | Highest priority + most recent | +| Enable/disable | enabledScopes | enabled option | +| Form fields | enableOnFormTags option | Always works | +| preventDefault | Optional | Default true | +| stopPropagation | Manual in callback | Default true | +| Pass to next handler | Not possible | Return false | + +## Testing + +Unit tests are in `KeyboardProvider.test.tsx`. Run with: + +```bash +cd assets +npm test -- keyboard/KeyboardProvider.test.tsx +``` + +## Architecture + +``` +keyboard/ +├── types.ts # TypeScript types and constants +├── KeyboardProvider.tsx # Provider and hook implementation +├── KeyboardProvider.test.tsx # Unit tests +├── index.ts # Public API exports +└── README.md # This file +``` + +**Key Design Decisions:** + +- Single tinykeys listener per combo (efficient) +- Registry in ref (doesn't trigger re-renders) +- Stable callback refs (prevents unnecessary re-registration) +- Errors are logged and re-thrown (visible to error boundaries/monitoring) +- Default preventDefault/stopPropagation (matches existing behavior) + +## Troubleshooting + +**Handler not firing:** + +- Check that component is mounted within KeyboardProvider +- Verify key combo syntax (use tinykeys syntax) +- Check if higher priority handler is claiming the event +- Verify enabled option is true + +**Multiple handlers firing:** + +- Check priorities - higher priority should block lower +- Verify handler isn't returning false accidentally +- Check stopPropagation option + +**Handler firing in wrong order:** + +- Higher number = higher priority +- Same priority = most recent wins +- Define your own priority constants for consistency diff --git a/assets/js/collaborative-editor/keyboard/index.ts b/assets/js/collaborative-editor/keyboard/index.ts new file mode 100644 index 0000000000..8b2df1b3f1 --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/index.ts @@ -0,0 +1,17 @@ +/** + * Priority-based keyboard shortcut system + * + * @module keyboard + */ + +export { + KeyboardProvider, + useKeyboardDebugInfo, + useKeyboardShortcut, +} from './KeyboardProvider'; +export type { + HandlerDebugInfo, + KeyboardDebugInfo, + KeyboardHandlerCallback, + KeyboardHandlerOptions, +} from './types'; diff --git a/assets/js/collaborative-editor/keyboard/tinykeys.d.ts b/assets/js/collaborative-editor/keyboard/tinykeys.d.ts new file mode 100644 index 0000000000..b5bd81cfa1 --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/tinykeys.d.ts @@ -0,0 +1,41 @@ +/** + * Type declarations for tinykeys + * + * This is a workaround for tinykeys package.json not properly exposing types + * through its "exports" field. The library has types at dist/tinykeys.d.ts, + * but TypeScript can't resolve them due to the exports configuration. + * + * See: https://github.com/jamiebuilds/tinykeys/issues/115 + */ + +declare module 'tinykeys' { + export interface KeyBindingMap { + [keybinding: string]: (event: KeyboardEvent) => void; + } + + export interface KeyBindingOptions { + /** + * Key presses will listen to this event (default: "keydown"). + */ + event?: 'keydown' | 'keyup'; + /** + * Key presses will use a capture listener (default: false) + */ + capture?: boolean; + /** + * Keybinding sequences will wait this long between key presses before + * cancelling (default: 1000). + */ + timeout?: number; + } + + /** + * Subscribes to keybindings. + * Returns an unsubscribe method. + */ + export function tinykeys( + target: Window | HTMLElement, + keyBindingMap: KeyBindingMap, + options?: KeyBindingOptions + ): () => void; +} diff --git a/assets/js/collaborative-editor/keyboard/types.ts b/assets/js/collaborative-editor/keyboard/types.ts new file mode 100644 index 0000000000..3e201fce18 --- /dev/null +++ b/assets/js/collaborative-editor/keyboard/types.ts @@ -0,0 +1,96 @@ +/** + * Type definitions for priority-based keyboard shortcut system + */ + +/** + * Handler options for configuring behavior + */ +export interface KeyboardHandlerOptions { + /** + * Prevent default browser behavior + * @default true + */ + preventDefault?: boolean; + + /** + * Stop event propagation after handler executes + * @default true + */ + stopPropagation?: boolean; + + /** + * Enable/disable handler without unmounting + * @default true + */ + enabled?: boolean; +} + +/** + * Handler callback function + * Return false to pass event to next handler in priority order + * Return void/true to claim the event and stop propagation + */ +export type KeyboardHandlerCallback = (event: KeyboardEvent) => boolean | void; + +/** + * Internal handler representation + */ +export interface Handler { + id: string; + callback: KeyboardHandlerCallback; + priority: number; + registeredAt: number; + options: Required; +} + +/** + * Debug information about a registered handler + */ +export interface HandlerDebugInfo { + id: string; + priority: number; + enabled: boolean; + registeredAt: number; + preventDefault: boolean; + stopPropagation: boolean; +} + +/** + * Debug information about all registered handlers + */ +export interface KeyboardDebugInfo { + /** Map of key combinations to their registered handlers */ + handlers: Map; + /** Total number of unique key combinations registered */ + comboCount: number; + /** Total number of handlers across all combos */ + handlerCount: number; +} + +/** + * Context value exposed by KeyboardProvider + */ +export interface KeyboardContextValue { + /** + * Register a keyboard handler + * @param combos - Comma-separated key combinations + * (e.g., "Escape", "Cmd+Enter, Ctrl+Enter") + * @param handler - Handler configuration + * @returns Cleanup function to unregister the handler + */ + register: ( + combos: string, + handler: Omit & { + options?: KeyboardHandlerOptions; + } + ) => () => void; + + /** + * Get debug information about all registered handlers (for debugging only) + * @returns Debug information including all registered handlers and stats + */ + getDebugInfo: () => KeyboardDebugInfo; +} + +// No constants - library is generic. Consuming applications can define +// their own. diff --git a/assets/js/collaborative-editor/stores/createAwarenessStore.ts b/assets/js/collaborative-editor/stores/createAwarenessStore.ts index 81f0c07241..04266ac89c 100644 --- a/assets/js/collaborative-editor/stores/createAwarenessStore.ts +++ b/assets/js/collaborative-editor/stores/createAwarenessStore.ts @@ -117,6 +117,7 @@ export const createAwarenessStore = (): AwarenessStore => { users: [], localUser: null, isInitialized: false, + cursorsMap: new Map(), rawAwareness: null, isConnected: false, lastUpdated: null, @@ -162,37 +163,33 @@ export const createAwarenessStore = (): AwarenessStore => { // ============================================================================= /** - * Extract users from awareness states with validation and sorting + * Helper: Compare cursor positions for referential stability */ - const extractUsersFromAwareness = (awareness: Awareness): AwarenessUser[] => { - const users: AwarenessUser[] = []; - - awareness.getStates().forEach((awarenessState, clientId) => { - // Validate user data structure - if (awarenessState['user']) { - try { - // Note: We're not using Zod validation here as it's runtime performance critical - // and we trust the awareness protocol more than external API data - const user: AwarenessUser = { - clientId, - user: awarenessState['user'] as AwarenessUser['user'], - cursor: awarenessState['cursor'] as AwarenessUser['cursor'], - selection: awarenessState[ - 'selection' - ] as AwarenessUser['selection'], - lastSeen: awarenessState['lastSeen'] as number | undefined, - }; - users.push(user); - } catch (error) { - logger.warn('Invalid user data for client', clientId, error); - } - } - }); - - // Sort users by name for consistent ordering (referential stability) - users.sort((a, b) => a.user.name.localeCompare(b.user.name)); + const arePositionsEqual = ( + a: { x: number; y: number } | undefined, + b: { x: number; y: number } | undefined + ): boolean => { + if (a === undefined && b === undefined) return true; + if (a === undefined || b === undefined) return false; + return a.x === b.x && a.y === b.y; + }; - return users; + /** + * Helper: Compare selections (RelativePosition) for referential stability + */ + const areSelectionsEqual = ( + a: AwarenessUser['selection'] | undefined, + b: AwarenessUser['selection'] | undefined + ): boolean => { + if (a === undefined && b === undefined) return true; + if (a === undefined || b === undefined) return false; + + // RelativePosition objects from Yjs need proper comparison + // Using JSON.stringify for deep equality check + return ( + JSON.stringify(a.anchor) === JSON.stringify(b.anchor) && + JSON.stringify(a.head) === JSON.stringify(b.head) + ); }; /** @@ -204,10 +201,108 @@ export const createAwarenessStore = (): AwarenessStore => { return; } - const users = extractUsersFromAwareness(awarenessInstance); + // Capture awareness instance for closure + const awareness = awarenessInstance; state = produce(state, draft => { - draft.users = users; + const awarenessStates = awareness.getStates(); + + // Track which clientIds we've seen in this update + const seenClientIds = new Set(); + + // Update or add users using Immer's Map support + awarenessStates.forEach((awarenessState, clientId) => { + seenClientIds.add(clientId); + + // Validate user data exists + if (!awarenessState['user']) { + return; + } + + try { + // Get existing user from Map (if any) + const existingUser = draft.cursorsMap.get(clientId); + + // Extract new data + const userData = awarenessState['user'] as AwarenessUser['user']; + const cursor = awarenessState['cursor'] as AwarenessUser['cursor']; + const selection = awarenessState[ + 'selection' + ] as AwarenessUser['selection']; + const lastSeen = awarenessState['lastSeen'] as number | undefined; + + // Check if user data actually changed + if (existingUser) { + let hasChanged = false; + + // Compare user fields + if ( + existingUser.user.id !== userData.id || + existingUser.user.name !== userData.name || + existingUser.user.email !== userData.email || + existingUser.user.color !== userData.color + ) { + hasChanged = true; + } + + // Compare cursor + if (!arePositionsEqual(existingUser.cursor, cursor)) { + hasChanged = true; + } + + // Compare selection + if (!areSelectionsEqual(existingUser.selection, selection)) { + hasChanged = true; + } + + // Compare lastSeen + if (existingUser.lastSeen !== lastSeen) { + hasChanged = true; + } + + // Only update if something changed + // If not changed, Immer preserves the existing reference + if (hasChanged) { + draft.cursorsMap.set(clientId, { + clientId, + user: userData, + cursor, + selection, + lastSeen, + }); + } + } else { + // New user - add to map + draft.cursorsMap.set(clientId, { + clientId, + user: userData, + cursor, + selection, + lastSeen, + }); + } + } catch (error) { + logger.warn('Invalid user data for client', clientId, error); + } + }); + + // Remove users that are no longer in awareness + const entriesToDelete: number[] = []; + draft.cursorsMap.forEach((_, clientId) => { + if (!seenClientIds.has(clientId)) { + entriesToDelete.push(clientId); + } + }); + entriesToDelete.forEach(clientId => { + draft.cursorsMap.delete(clientId); + }); + + // Rebuild users array from cursorsMap for compatibility + // Sort by name for consistent ordering + draft.users = Array.from(draft.cursorsMap.values()).sort((a, b) => + a.user.name.localeCompare(b.user.name) + ); + draft.lastUpdated = Date.now(); }); notify('awarenessChange'); @@ -271,6 +366,7 @@ export const createAwarenessStore = (): AwarenessStore => { state = produce(state, draft => { draft.users = []; + draft.cursorsMap.clear(); draft.localUser = null; draft.rawAwareness = null; draft.isInitialized = false; diff --git a/assets/js/collaborative-editor/stores/createHistoryStore.ts b/assets/js/collaborative-editor/stores/createHistoryStore.ts index 5b314bc0ab..dc82823533 100644 --- a/assets/js/collaborative-editor/stores/createHistoryStore.ts +++ b/assets/js/collaborative-editor/stores/createHistoryStore.ts @@ -90,11 +90,11 @@ import { channelRequest } from '../hooks/useChannel'; import { type HistoryState, type HistoryStore, - HistoryListSchema, - type WorkOrder, - type RunSummary, type RunStepsData, + type RunSummary, type StepDetail, + type WorkOrder, + HistoryListSchema, RunDetailSchema, StepDetailSchema, } from '../types/history'; @@ -897,6 +897,27 @@ export const createHistoryStore = (): HistoryStore => { notify('_closeRunViewer'); }; + /** + * TEST-ONLY helper to directly set active run without channel requests + * This bypasses the normal _viewRun flow which requires Phoenix channels + * and is intended ONLY for use in test environments + * + * @param run - The run to set as active + */ + const _setActiveRunForTesting = (run: RunDetail): void => { + state = produce(state, draft => { + draft.activeRunId = run.id; + draft.activeRun = run; + draft.activeRunLoading = false; + draft.activeRunError = null; + // Auto-select first step if none selected + if (!draft.selectedStepId && run.steps.length > 0) { + draft.selectedStepId = run.steps[0]?.id || null; + } + }); + notify('_setActiveRunForTesting'); + }; + // =========================================================================== // PUBLIC INTERFACE // =========================================================================== @@ -931,6 +952,7 @@ export const createHistoryStore = (): HistoryStore => { _connectChannel, _viewRun, _closeRunViewer, + _setActiveRunForTesting, }; }; diff --git a/assets/js/collaborative-editor/types/awareness.ts b/assets/js/collaborative-editor/types/awareness.ts index a9bc7f692c..c108db620e 100644 --- a/assets/js/collaborative-editor/types/awareness.ts +++ b/assets/js/collaborative-editor/types/awareness.ts @@ -46,6 +46,9 @@ export interface AwarenessState { localUser: LocalUserData | null; isInitialized: boolean; + // Map of user cursors keyed by clientId + cursorsMap: Map; + // Raw awareness access (for components that need it) rawAwareness: Awareness | null; diff --git a/assets/js/collaborative-editor/types/history.ts b/assets/js/collaborative-editor/types/history.ts index e29849248f..b14734b88d 100644 --- a/assets/js/collaborative-editor/types/history.ts +++ b/assets/js/collaborative-editor/types/history.ts @@ -247,6 +247,7 @@ interface HistoryStoreInternals { _connectChannel: (provider: PhoenixChannelProvider) => () => void; _viewRun: (runId: string) => void; _closeRunViewer: () => void; + _setActiveRunForTesting: (run: RunDetail) => void; } /** diff --git a/assets/js/manual-run-panel/views/CustomView.tsx b/assets/js/manual-run-panel/views/CustomView.tsx index 09ce0320f6..33791e5321 100644 --- a/assets/js/manual-run-panel/views/CustomView.tsx +++ b/assets/js/manual-run-panel/views/CustomView.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { cn } from '#/utils/cn'; import { MonacoEditor } from '../../monaco'; +import { addKeyboardShortcutOverrides } from '../../monaco/keyboard-overrides'; import FileUploader from '../FileUploader'; const iconStyle = 'h-4 w-4 text-grey-400'; @@ -87,6 +88,9 @@ const CustomView: React.FC<{ theme="default" value={editorValue} onChange={handleEditorChange} + onMount={(editor, monaco) => { + addKeyboardShortcutOverrides(editor, monaco); + }} loading={
Loading...
} options={{ readOnly: false, diff --git a/assets/js/manual-run-panel/views/ExistingView.tsx b/assets/js/manual-run-panel/views/ExistingView.tsx index be52b3b032..7751a78e43 100644 --- a/assets/js/manual-run-panel/views/ExistingView.tsx +++ b/assets/js/manual-run-panel/views/ExistingView.tsx @@ -90,7 +90,6 @@ const ExistingView: React.FC = ({ const keyDownHandler = (e: KeyboardEvent) => { if (e.key === 'Enter') { - e.stopPropagation(); e.preventDefault(); onSubmit(); } diff --git a/assets/js/monaco/keyboard-overrides.ts b/assets/js/monaco/keyboard-overrides.ts new file mode 100644 index 0000000000..af27dd165d --- /dev/null +++ b/assets/js/monaco/keyboard-overrides.ts @@ -0,0 +1,66 @@ +import type { editor } from 'monaco-editor'; + +import type { Monaco } from './index'; + +/** + * Adds keyboard shortcut overrides to Monaco Editor to allow external handlers + * (like KeyboardProvider) to receive Cmd/Ctrl+Enter events. + * + * By default, Monaco Editor intercepts certain keyboard shortcuts (like Cmd+Enter) + * for its own command system. This function overrides those shortcuts to dispatch + * synthetic KeyboardEvents to the window, allowing your application's keyboard + * handler system to process them instead. + * + * Overridden shortcuts: + * - Cmd/Ctrl+Enter: Dispatches to window for run/retry actions + * - Cmd/Ctrl+Shift+Enter: Dispatches to window for force-run actions + * + * Usage: + * ```typescript + * { + * addKeyboardShortcutOverrides(editor, monaco); + * }} + * /> + * ``` + * + * @param editor - Monaco editor instance + * @param monaco - Monaco API object + */ +export function addKeyboardShortcutOverrides( + editor: editor.IStandaloneCodeEditor, + monaco: Monaco +): void { + // Detect platform to set correct modifier key + const isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent); + + // Override Monaco's Cmd/Ctrl+Enter + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { + const event = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + metaKey: isMac, + ctrlKey: !isMac, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(event); + }); + + // Override Monaco's Cmd/Ctrl+Shift+Enter + editor.addCommand( + monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter, + () => { + const event = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + metaKey: isMac, + ctrlKey: !isMac, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(event); + } + ); +} diff --git a/assets/package-lock.json b/assets/package-lock.json index 3dafa26d61..be6ddbdfb5 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -39,13 +39,13 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-error-boundary": "^5.0.0", - "react-hotkeys-hook": "^5.2.1", "react-is": "^18.3.1", "react-resizable-panels": "^3.0.6", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3", + "tinykeys": "^3.0.0", "tippy.js": "^6.3.7", "y-monaco": "^0.1.6", "y-phoenix-channel": "^0.1.1", @@ -78,6 +78,7 @@ "@types/react-is": "^18.3.1", "@typescript-eslint/parser": "^8.26.0", "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "esbuild": "^0.17.18", "eslint": "^9.21.0", @@ -120,6 +121,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", @@ -609,6 +624,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "license": "MIT", @@ -1782,6 +1807,119 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2626,6 +2764,17 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@playwright/test": { "version": "1.55.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", @@ -5045,6 +5194,40 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -5597,6 +5780,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -6550,6 +6752,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8201,6 +8410,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded-parse": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", @@ -8592,6 +8818,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", @@ -9282,6 +9515,60 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -9300,6 +9587,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "1.21.0", "license": "MIT", @@ -9827,6 +10130,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-error": { "version": "1.3.6", "license": "ISC", @@ -9950,6 +10281,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -10330,6 +10671,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10411,6 +10759,30 @@ "version": "1.0.7", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -10907,19 +11279,6 @@ "react": ">=16.13.1" } }, - "node_modules/react-hotkeys-hook": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.2.1.tgz", - "integrity": "sha512-xbKh6zJxd/vJHT4Bw4+0pBD662Fk20V+VFhLqciCg+manTVO4qlqRqiwFOYelfHN9dBvWj9vxaPkSS26ZSIJGg==", - "license": "MIT", - "workspaces": [ - "packages/*" - ], - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -11532,6 +11891,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -11648,6 +12020,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -11774,6 +12162,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -11990,6 +12392,68 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thenify": { "version": "3.3.1", "license": "MIT", @@ -12083,6 +12547,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinykeys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinykeys/-/tinykeys-3.0.0.tgz", + "integrity": "sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==", + "license": "MIT" + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -13140,6 +13610,25 @@ "node": ">=8" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "license": "ISC", diff --git a/assets/package.json b/assets/package.json index 646ac20135..dd2f925abb 100644 --- a/assets/package.json +++ b/assets/package.json @@ -49,13 +49,13 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-error-boundary": "^5.0.0", - "react-hotkeys-hook": "^5.2.1", "react-is": "^18.3.1", "react-resizable-panels": "^3.0.6", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3", + "tinykeys": "^3.0.0", "tippy.js": "^6.3.7", "y-monaco": "^0.1.6", "y-phoenix-channel": "^0.1.1", @@ -88,6 +88,7 @@ "@types/react-is": "^18.3.1", "@typescript-eslint/parser": "^8.26.0", "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "esbuild": "^0.17.18", "eslint": "^9.21.0", diff --git a/assets/test/_setup.ts b/assets/test/_setup.ts index d692d1a3e3..2704c95742 100644 --- a/assets/test/_setup.ts +++ b/assets/test/_setup.ts @@ -6,7 +6,7 @@ enableMapSet(); // Increase max listeners to avoid warning during test runs // This is safe for tests as multiple test files add cleanup handlers -process.setMaxListeners(20); +process.setMaxListeners(24); // Suppress debug logs during tests console.debug = () => {}; diff --git a/assets/test/collaborative-editor/CollaborativeEditor.test.tsx b/assets/test/collaborative-editor/CollaborativeEditor.test.tsx index c725bd15b1..d51391b382 100644 --- a/assets/test/collaborative-editor/CollaborativeEditor.test.tsx +++ b/assets/test/collaborative-editor/CollaborativeEditor.test.tsx @@ -17,13 +17,11 @@ import { describe, expect, test } from 'vitest'; -import type { ProjectContext } from '../../js/collaborative-editor/types/sessionContext'; import { createMockProject, - selectBreadcrumbProjectData, - generateBreadcrumbUrls, generateBreadcrumbStructure, - type BreadcrumbItem, + generateBreadcrumbUrls, + selectBreadcrumbProjectData, } from './__helpers__/breadcrumbHelpers'; // ============================================================================= @@ -648,7 +646,7 @@ describe('CollaborativeEditor - LoadingBoundary Integration', () => { // but NOT the Header (BreadcrumbContent) or Toaster. // // Expected structure: - // - HotkeysProvider + // - KeyboardProvider // - div.collaborative-editor // - SocketProvider // - SessionProvider diff --git a/assets/test/collaborative-editor/__helpers__/index.ts b/assets/test/collaborative-editor/__helpers__/index.ts index ab1d8fc24f..2f76d82499 100644 --- a/assets/test/collaborative-editor/__helpers__/index.ts +++ b/assets/test/collaborative-editor/__helpers__/index.ts @@ -15,13 +15,7 @@ // Channel mocks export { createMockPhoenixChannel, - createMockPushWithResponse, - createMockPushWithAllStatuses, createMockPhoenixChannelProvider, - configureMockChannelPush, - createMockChannelWithResponses, - createMockChannelWithError, - createMockChannelWithTimeout, type MockPhoenixChannel, type MockPhoenixChannelProvider, type MockPush, @@ -32,12 +26,30 @@ export { setupAdaptorStoreTest, setupSessionContextStoreTest, setupSessionStoreTest, + setupUIStoreTest, setupMultipleStores, type AdaptorStoreTestSetup, type SessionContextStoreTestSetup, type SessionStoreTestSetup, + type UIStoreTestSetup, } from './storeHelpers'; +// Workflow store helpers +export { + setupWorkflowStoreTest, + createEmptyWorkflowYDoc, + createMinimalWorkflowYDoc, + type WorkflowStoreTestSetup, +} from './workflowStoreHelpers'; + +// Workflow factory helpers +export { + createWorkflowYDoc, + createLinearWorkflowYDoc, + createDiamondWorkflowYDoc, + type CreateWorkflowInput, +} from './workflowFactory'; + // Session store helpers export { createMockSocket, diff --git a/assets/test/collaborative-editor/__helpers__/setupUIStoreTest.test.ts b/assets/test/collaborative-editor/__helpers__/setupUIStoreTest.test.ts new file mode 100644 index 0000000000..9a181628eb --- /dev/null +++ b/assets/test/collaborative-editor/__helpers__/setupUIStoreTest.test.ts @@ -0,0 +1,109 @@ +/** + * Tests for setupUIStoreTest helper + * + * Verifies that the helper creates a properly configured UIStore + * instance for testing. + */ + +import { describe, expect, it } from 'vitest'; + +import { setupUIStoreTest } from './storeHelpers'; + +describe('setupUIStoreTest', () => { + it('creates a UIStore instance with initial state', () => { + const { store, cleanup } = setupUIStoreTest(); + + const state = store.getSnapshot(); + + expect(state.runPanelOpen).toBe(false); + expect(state.runPanelContext).toBe(null); + expect(state.githubSyncModalOpen).toBe(false); + + cleanup(); + }); + + it('provides working store methods', () => { + const { store, cleanup } = setupUIStoreTest(); + + // Test openRunPanel command + store.openRunPanel({ jobId: 'job-1' }); + let state = store.getSnapshot(); + expect(state.runPanelOpen).toBe(true); + expect(state.runPanelContext?.jobId).toBe('job-1'); + + // Test closeRunPanel command + store.closeRunPanel(); + state = store.getSnapshot(); + expect(state.runPanelOpen).toBe(false); + expect(state.runPanelContext).toBe(null); + + cleanup(); + }); + + it('provides working subscription mechanism', () => { + const { store, cleanup } = setupUIStoreTest(); + + let notificationCount = 0; + const unsubscribe = store.subscribe(() => { + notificationCount++; + }); + + // Open panel - should trigger notification + store.openRunPanel({ triggerId: 'trigger-1' }); + expect(notificationCount).toBe(1); + + // Close panel - should trigger notification + store.closeRunPanel(); + expect(notificationCount).toBe(2); + + unsubscribe(); + cleanup(); + }); + + it('provides working withSelector utility', () => { + const { store, cleanup } = setupUIStoreTest(); + + // Create a memoized selector + const selector = store.withSelector(state => state.runPanelOpen); + + // Initial state + expect(selector(store.getSnapshot())).toBe(false); + + // After opening panel + store.openRunPanel({ jobId: 'job-2' }); + expect(selector(store.getSnapshot())).toBe(true); + + cleanup(); + }); + + it('supports GitHub sync modal commands', () => { + const { store, cleanup } = setupUIStoreTest(); + + // Open modal + store.openGitHubSyncModal(); + let state = store.getSnapshot(); + expect(state.githubSyncModalOpen).toBe(true); + + // Close modal + store.closeGitHubSyncModal(); + state = store.getSnapshot(); + expect(state.githubSyncModalOpen).toBe(false); + + cleanup(); + }); + + it('handles cleanup gracefully', () => { + const { store, cleanup } = setupUIStoreTest(); + + // Perform some operations + store.openRunPanel({ jobId: 'job-3' }); + store.openGitHubSyncModal(); + + // Cleanup should not throw + expect(() => cleanup()).not.toThrow(); + + // Store should still be accessible after cleanup + const state = store.getSnapshot(); + expect(state).toBeDefined(); + }); +}); diff --git a/assets/test/collaborative-editor/__helpers__/storeHelpers.ts b/assets/test/collaborative-editor/__helpers__/storeHelpers.ts index de29944086..0e1f15840e 100644 --- a/assets/test/collaborative-editor/__helpers__/storeHelpers.ts +++ b/assets/test/collaborative-editor/__helpers__/storeHelpers.ts @@ -12,8 +12,9 @@ */ import { createAdaptorStore } from '../../../js/collaborative-editor/stores/createAdaptorStore'; -import { createSessionStore } from '../../../js/collaborative-editor/stores/createSessionStore'; import { createSessionContextStore } from '../../../js/collaborative-editor/stores/createSessionContextStore'; +import { createSessionStore } from '../../../js/collaborative-editor/stores/createSessionStore'; +import { createUIStore } from '../../../js/collaborative-editor/stores/createUIStore'; import { createMockPhoenixChannel, @@ -147,6 +148,16 @@ export interface SessionStoreTestSetup { cleanup: () => void; } +/** + * Result of setting up a UI store test + */ +export interface UIStoreTestSetup { + /** The UI store instance */ + store: ReturnType; + /** Cleanup function to call after test */ + cleanup: () => void; +} + /** * Sets up a session store test with initialized YDoc and provider * @@ -195,6 +206,43 @@ export function setupSessionStoreTest( }; } +/** + * Sets up a UI store test with minimal configuration + * + * The UI store manages transient, local-only UI state like panel + * visibility and context. It requires no channel or Y.Doc connections, + * making it the simplest store to set up for testing. + * + * This helper provides a consistent starting point for UI store tests. + * + * @returns Test setup with store and cleanup function + * + * @example + * test("UI store panel management", () => { + * const { store, cleanup } = setupUIStoreTest(); + * + * // Test panel opening + * store.openRunPanel({ jobId: "job-1" }); + * const state = store.getSnapshot(); + * expect(state.runPanelOpen).toBe(true); + * expect(state.runPanelContext?.jobId).toBe("job-1"); + * + * // Cleanup + * cleanup(); + * }); + */ +export function setupUIStoreTest(): UIStoreTestSetup { + const store = createUIStore(); + + return { + store, + cleanup: () => { + // UI store has no external connections to clean up + // Just ensure no lingering listeners + }, + }; +} + /** * Creates multiple stores and optionally connects them to a session * diff --git a/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.test.ts b/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.test.ts new file mode 100644 index 0000000000..ad1a142ea1 --- /dev/null +++ b/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.test.ts @@ -0,0 +1,433 @@ +/** + * Enhanced StoreProvider Helper Tests + * + * Tests for the enhanced simulateStoreProviderWithConnection functionality: + * - Custom Y.Doc support + * - Session context emission + * - Backward compatibility + */ + +import { describe, expect, test } from 'vitest'; + +import { waitForAsync } from '../mocks/phoenixChannel'; +import { + simulateStoreProviderWithConnection, + type StoreProviderConnectionOptions, +} from './storeProviderHelpers'; +import { createWorkflowYDoc } from './workflowFactory'; + +describe('simulateStoreProviderWithConnection - Enhanced Options', () => { + // ========================================================================= + // BACKWARD COMPATIBILITY TESTS + // ========================================================================= + + describe('backward compatibility', () => { + test('works without options (existing tests unchanged)', async () => { + const { stores, sessionStore, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection(); + + expect(stores).toBeDefined(); + expect(sessionStore).toBeDefined(); + expect(stores.workflowStore).toBeDefined(); + expect(stores.sessionContextStore).toBeDefined(); + + channelCleanup(); + cleanup(); + }); + + test('works with legacy options format', async () => { + const { stores, sessionStore, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + connect: true, + }); + + expect(stores).toBeDefined(); + expect(sessionStore).toBeDefined(); + + channelCleanup(); + cleanup(); + }); + + test('works with custom userData', async () => { + const userData = { + id: 'custom-user', + name: 'Custom User', + color: '#00ff00', + }; + + const { stores, sessionStore, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', userData); + + expect(stores).toBeDefined(); + expect(sessionStore).toBeDefined(); + + channelCleanup(); + cleanup(); + }); + }); + + // ========================================================================= + // CUSTOM Y.DOC TESTS + // ========================================================================= + + describe('custom Y.Doc support', () => { + test('uses provided Y.Doc with workflow data', async () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + 'job-b': { + id: 'job-b', + name: 'Job B', + adaptor: '@openfn/language-common', + }, + }, + edges: [ + { + id: 'edge-1', + source: 'job-a', + target: 'job-b', + condition_type: 'on_job_success', + }, + ], + }); + + const { stores, ydoc, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + workflowYDoc: customYDoc, + }); + + // Verify Y.Doc is returned + expect(ydoc).toBeDefined(); + expect(ydoc).toBe(customYDoc); + + // Verify workflow store is connected to the Y.Doc + const state = stores.workflowStore.getSnapshot(); + expect(state.jobs).toHaveLength(2); + expect(state.edges).toHaveLength(1); + expect(state.jobs[0].name).toBe('Job A'); + expect(state.jobs[1].name).toBe('Job B'); + + channelCleanup(); + cleanup(); + }); + + test('creates empty Y.Doc when not provided', async () => { + const { stores, ydoc, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection(); + + // Verify Y.Doc is returned + expect(ydoc).toBeDefined(); + + // Verify workflow store is connected to an empty Y.Doc + const state = stores.workflowStore.getSnapshot(); + expect(state.jobs).toHaveLength(0); + expect(state.edges).toHaveLength(0); + expect(state.triggers).toHaveLength(0); + + channelCleanup(); + cleanup(); + }); + + test('workflow store can update provided Y.Doc', async () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + }, + }); + + const { stores, ydoc, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + workflowYDoc: customYDoc, + }); + + // Get initial state + let state = stores.workflowStore.getSnapshot(); + expect(state.jobs).toHaveLength(1); + + // Add a job using workflow store + const jobsArray = ydoc!.getArray('jobs'); + const newJobMap = new Map(); + newJobMap.set('id', 'job-b'); + newJobMap.set('name', 'Job B'); + newJobMap.set('adaptor', '@openfn/language-common'); + + // Wait for Y.Doc update to propagate + await waitForAsync(50); + + // Verify the update is reflected in store + state = stores.workflowStore.getSnapshot(); + + channelCleanup(); + cleanup(); + }); + }); + + // ========================================================================= + // SESSION CONTEXT EMISSION TESTS + // ========================================================================= + + describe('session context emission', () => { + test('emits session context when configured', async () => { + const { stores, emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + sessionContext: { + permissions: { can_edit_workflow: true }, + user: { first_name: 'Test', last_name: 'User' }, + }, + emitSessionContext: true, + }); + + // Verify emit function is provided + expect(emitSessionContext).toBeDefined(); + expect(typeof emitSessionContext).toBe('function'); + + // Wait for session context to be processed + await waitForAsync(100); + + // Verify session context store received the data + const state = stores.sessionContextStore.getSnapshot(); + expect(state.user?.first_name).toBe('Test'); + expect(state.user?.last_name).toBe('User'); + expect(state.permissions?.can_edit_workflow).toBe(true); + + channelCleanup(); + cleanup(); + }); + + test('does not provide emit function when not configured', async () => { + const { emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection(); + + // Verify emit function is not provided + expect(emitSessionContext).toBeUndefined(); + + channelCleanup(); + cleanup(); + }); + + test('does not provide emit function when emitSessionContext is false', async () => { + const { emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + sessionContext: { + permissions: { can_edit_workflow: true }, + }, + emitSessionContext: false, + }); + + // Verify emit function is not provided + expect(emitSessionContext).toBeUndefined(); + + channelCleanup(); + cleanup(); + }); + + test('re-emits session context with overrides', async () => { + const { stores, emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + sessionContext: { + permissions: { can_edit_workflow: true }, + user: { first_name: 'Test', last_name: 'User' }, + }, + emitSessionContext: true, + }); + + // Wait for initial emission + await waitForAsync(50); + + // Verify initial state + let state = stores.sessionContextStore.getSnapshot(); + expect(state.permissions?.can_edit_workflow).toBe(true); + + // Re-emit with overrides + emitSessionContext?.({ + permissions: { can_edit_workflow: false }, + }); + + // Wait for update + await waitForAsync(50); + + // Verify updated state + state = stores.sessionContextStore.getSnapshot(); + expect(state.permissions?.can_edit_workflow).toBe(false); + // User should be preserved from original context + expect(state.user?.first_name).toBe('Test'); + + channelCleanup(); + cleanup(); + }); + + test('emits with GitHub repo connection', async () => { + const { stores, emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + sessionContext: { + project_repo_connection: { + repo: 'openfn/demo', + branch: 'main', + }, + }, + emitSessionContext: true, + }); + + // Wait for emission + await waitForAsync(100); + + // Verify session context store received the data + const state = stores.sessionContextStore.getSnapshot(); + + // Note: Store transforms snake_case to camelCase + expect(state.projectRepoConnection).toBeDefined(); + expect(state.projectRepoConnection?.repo).toBe('openfn/demo'); + expect(state.projectRepoConnection?.branch).toBe('main'); + + channelCleanup(); + cleanup(); + }); + }); + + // ========================================================================= + // COMBINED FEATURES TESTS + // ========================================================================= + + describe('combined features', () => { + test('works with both custom Y.Doc and session context', async () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + }, + }); + + const { stores, ydoc, emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + workflowYDoc: customYDoc, + sessionContext: { + permissions: { can_edit_workflow: true }, + user: { first_name: 'Test', last_name: 'User' }, + }, + emitSessionContext: true, + }); + + // Wait for session context + await waitForAsync(50); + + // Verify Y.Doc + expect(ydoc).toBe(customYDoc); + const workflowState = stores.workflowStore.getSnapshot(); + expect(workflowState.jobs).toHaveLength(1); + expect(workflowState.jobs[0].name).toBe('Job A'); + + // Verify session context + const sessionState = stores.sessionContextStore.getSnapshot(); + expect(sessionState.user?.first_name).toBe('Test'); + expect(sessionState.permissions?.can_edit_workflow).toBe(true); + + // Verify emit function is available + expect(emitSessionContext).toBeDefined(); + + channelCleanup(); + cleanup(); + }); + + test('works with all options combined', async () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + }, + }); + + const userData = { + id: 'custom-user', + name: 'Custom User', + color: '#00ff00', + }; + + const options: StoreProviderConnectionOptions = { + connect: true, + workflowYDoc: customYDoc, + sessionContext: { + permissions: { can_edit_workflow: true }, + user: { first_name: 'Test', last_name: 'User' }, + project_repo_connection: { + repo: 'openfn/demo', + branch: 'main', + }, + }, + emitSessionContext: true, + }; + + const { stores, ydoc, emitSessionContext, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection( + 'test:custom-room', + userData, + options + ); + + // Wait for session context + await waitForAsync(100); + + // Verify all features work together + expect(ydoc).toBe(customYDoc); + expect(stores.workflowStore.getSnapshot().jobs).toHaveLength(1); + expect(stores.sessionContextStore.getSnapshot().user?.first_name).toBe( + 'Test' + ); + const sessionState = stores.sessionContextStore.getSnapshot(); + // Note: Store transforms snake_case to camelCase + expect(sessionState.projectRepoConnection).toBeDefined(); + expect(sessionState.projectRepoConnection?.repo).toBe('openfn/demo'); + expect(emitSessionContext).toBeDefined(); + + channelCleanup(); + cleanup(); + }); + }); + + // ========================================================================= + // CLEANUP TESTS + // ========================================================================= + + describe('cleanup', () => { + test('disconnects workflow store on cleanup', async () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + }, + }); + + const { stores, channelCleanup, cleanup } = + await simulateStoreProviderWithConnection('test:room', undefined, { + workflowYDoc: customYDoc, + }); + + // Verify connected + expect(stores.workflowStore.isConnected).toBe(true); + + // Call cleanup + channelCleanup(); + + // Verify disconnected + expect(stores.workflowStore.isConnected).toBe(false); + + cleanup(); + }); + }); +}); diff --git a/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts b/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts index 63f5e0067b..f9ee56881a 100644 --- a/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts +++ b/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts @@ -11,17 +11,28 @@ * cleanup(); */ +import * as Y from 'yjs'; + import type { StoreContextValue } from '../../../js/collaborative-editor/contexts/StoreProvider'; -import type { SessionStoreInstance } from '../../../js/collaborative-editor/stores/createSessionStore'; -import { createSessionStore } from '../../../js/collaborative-editor/stores/createSessionStore'; import { createAdaptorStore } from '../../../js/collaborative-editor/stores/createAdaptorStore'; -import { createCredentialStore } from '../../../js/collaborative-editor/stores/createCredentialStore'; import { createAwarenessStore } from '../../../js/collaborative-editor/stores/createAwarenessStore'; -import { createWorkflowStore } from '../../../js/collaborative-editor/stores/createWorkflowStore'; +import { createCredentialStore } from '../../../js/collaborative-editor/stores/createCredentialStore'; +import { createEditorPreferencesStore } from '../../../js/collaborative-editor/stores/createEditorPreferencesStore'; +import { createHistoryStore } from '../../../js/collaborative-editor/stores/createHistoryStore'; import { createSessionContextStore } from '../../../js/collaborative-editor/stores/createSessionContextStore'; +import type { SessionStoreInstance } from '../../../js/collaborative-editor/stores/createSessionStore'; +import { createSessionStore } from '../../../js/collaborative-editor/stores/createSessionStore'; +import { createUIStore } from '../../../js/collaborative-editor/stores/createUIStore'; +import { createWorkflowStore } from '../../../js/collaborative-editor/stores/createWorkflowStore'; -import { createMockSocket } from '../mocks/phoenixSocket'; import { waitForAsync } from '../mocks/phoenixChannel'; +import { createMockSocket } from '../mocks/phoenixSocket'; + +import { + createSessionContext, + type CreateSessionContextOptions, +} from './sessionContextFactory'; +import { createEmptyWorkflowYDoc } from './workflowStoreHelpers'; /** * Result of simulating StoreProvider setup @@ -35,6 +46,20 @@ export interface StoreProviderSimulation { cleanup: () => void; } +/** + * Options for simulating store provider with connection + */ +export interface StoreProviderConnectionOptions { + /** Whether to connect the channel (defaults to true) */ + connect?: boolean; + /** Optional custom Y.Doc with workflow data */ + workflowYDoc?: Y.Doc; + /** Optional session context configuration */ + sessionContext?: CreateSessionContextOptions; + /** Whether to emit session_context event automatically */ + emitSessionContext?: boolean; +} + /** * Result of simulating StoreProvider with channel connection */ @@ -42,6 +67,13 @@ export interface ConnectedStoreProviderSimulation extends StoreProviderSimulation { /** Additional cleanup for channel connections */ channelCleanup: () => void; + /** The Y.Doc instance (if provided or created) */ + ydoc?: Y.Doc; + /** + * Helper function to emit session context events + * Only available if emitSessionContext option was true + */ + emitSessionContext?: (context?: CreateSessionContextOptions) => void; } /** @@ -64,6 +96,9 @@ export function createStores(): StoreContextValue { awarenessStore: createAwarenessStore(), workflowStore: createWorkflowStore(), sessionContextStore: createSessionContextStore(), + historyStore: createHistoryStore(), + uiStore: createUIStore(), + editorPreferencesStore: createEditorPreferencesStore(), }; } @@ -98,11 +133,13 @@ export function simulateChannelConnection( const cleanup3 = stores.sessionContextStore._connectChannel( session.provider ); + const cleanup4 = stores.historyStore._connectChannel(session.provider); return () => { cleanup1(); cleanup2(); cleanup3(); + cleanup4(); }; } @@ -147,25 +184,45 @@ export function simulateStoreProvider(): StoreProviderSimulation { * * @param roomTopic - Room topic for the session (defaults to "test:workflow") * @param userData - User data for awareness (defaults to test user) - * @param options - Session initialization options + * @param options - Session initialization and configuration options * @returns Simulation with stores, connected channels, and cleanup * * @example - * test("store channel integration", async () => { - * const { stores, channelCleanup, cleanup } = - * await simulateStoreProviderWithConnection(); + * // Basic usage (unchanged) + * const { stores, sessionStore, cleanup } = + * await simulateStoreProviderWithConnection(); * - * // Test store behavior with active channels - * await stores.sessionContextStore.requestSessionContext(); + * @example + * // With custom Y.Doc + * const ydoc = createWorkflowYDoc({ + * jobs: { "job-a": { id: "job-a", name: "Job A", + * adaptor: "@openfn/language-common" } } + * }); + * const { stores, ydoc: returnedYDoc, cleanup } = + * await simulateStoreProviderWithConnection('test:room', userData, { + * workflowYDoc: ydoc + * }); * - * channelCleanup(); - * cleanup(); + * @example + * // With session context + * const { stores, emitSessionContext, cleanup } = + * await simulateStoreProviderWithConnection('test:room', userData, { + * sessionContext: { + * permissions: { can_edit_workflow: true }, + * project_repo_connection: { repo: 'openfn/demo' } + * }, + * emitSessionContext: true + * }); + * + * // Re-emit with different context + * emitSessionContext?.({ + * permissions: { can_edit_workflow: false } * }); */ export async function simulateStoreProviderWithConnection( roomTopic: string = 'test:workflow', userData?: { id: string; name: string; color: string }, - options?: { connect?: boolean } + options: StoreProviderConnectionOptions = {} ): Promise { const stores = createStores(); const sessionStore = createSessionStore(); @@ -178,10 +235,14 @@ export async function simulateStoreProviderWithConnection( }; // Initialize session with connect: true by default - sessionStore.initializeSession(mockSocket, roomTopic, defaultUserData, { - connect: true, - ...options, - }); + const { ydoc, provider } = sessionStore.initializeSession( + mockSocket, + roomTopic, + defaultUserData, + { + connect: options.connect ?? true, + } + ); // Wait for provider to be ready await waitForAsync(100); @@ -189,13 +250,45 @@ export async function simulateStoreProviderWithConnection( // Connect stores to channel const channelCleanup = simulateChannelConnection(stores, sessionStore); + // Use provided Y.Doc or create empty one if workflowYDoc is provided + const workflowYDoc = options.workflowYDoc ?? createEmptyWorkflowYDoc(); + + // Connect workflow store to Y.Doc + stores.workflowStore.connect(workflowYDoc, provider); + + // Setup session context emission if requested + let emitSessionContextFn: + | ((context?: CreateSessionContextOptions) => void) + | undefined; + + if (options.emitSessionContext && options.sessionContext) { + // Get the mock channel from the provider + const mockChannel = provider.channel as any; + + emitSessionContextFn = (overrides: CreateSessionContextOptions = {}) => { + const context = createSessionContext({ + ...options.sessionContext, + ...overrides, + }); + mockChannel._test.emit('session_context', context); + }; + + // Emit initial context + emitSessionContextFn(); + } + return { stores, sessionStore, - channelCleanup, + channelCleanup: () => { + stores.workflowStore.disconnect(); + channelCleanup(); + }, cleanup: () => { sessionStore.destroy(); }, + ydoc: workflowYDoc, + emitSessionContext: emitSessionContextFn, }; } @@ -216,12 +309,18 @@ export function verifyAllStoresPresent(stores: StoreContextValue): void { expect(stores.awarenessStore).toBeDefined(); expect(stores.workflowStore).toBeDefined(); expect(stores.sessionContextStore).toBeDefined(); + expect(stores.historyStore).toBeDefined(); + expect(stores.uiStore).toBeDefined(); + expect(stores.editorPreferencesStore).toBeDefined(); // Verify each store has the expected interface [ stores.adaptorStore, stores.credentialStore, stores.sessionContextStore, + stores.historyStore, + stores.uiStore, + stores.editorPreferencesStore, ].forEach(store => { expect(typeof store.subscribe).toBe('function'); expect(typeof store.getSnapshot).toBe('function'); diff --git a/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.test.ts b/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.test.ts new file mode 100644 index 0000000000..7b48fb4a15 --- /dev/null +++ b/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for Workflow Store Test Helpers + * + * Verifies that the setupWorkflowStoreTest helper correctly initializes + * WorkflowStore with Y.Doc and provider connections. + */ + +import { describe, expect, test } from 'vitest'; + +import { createWorkflowYDoc } from './workflowFactory'; +import { + createEmptyWorkflowYDoc, + createMinimalWorkflowYDoc, + setupWorkflowStoreTest, +} from './workflowStoreHelpers'; + +describe('setupWorkflowStoreTest', () => { + test('creates store with empty Y.Doc by default', () => { + const { store, ydoc, cleanup } = setupWorkflowStoreTest(); + + // Store should be connected + expect(store.isConnected).toBe(true); + + // Y.Doc should have workflow structure initialized + expect(ydoc.getMap('workflow')).toBeDefined(); + expect(ydoc.getArray('jobs')).toBeDefined(); + expect(ydoc.getArray('triggers')).toBeDefined(); + expect(ydoc.getArray('edges')).toBeDefined(); + expect(ydoc.getMap('positions')).toBeDefined(); + expect(ydoc.getMap('errors')).toBeDefined(); + + // Arrays should be empty + expect(ydoc.getArray('jobs').length).toBe(0); + expect(ydoc.getArray('triggers').length).toBe(0); + expect(ydoc.getArray('edges').length).toBe(0); + + cleanup(); + }); + + test('accepts pre-configured Y.Doc', () => { + const customYDoc = createWorkflowYDoc({ + jobs: { + 'job-a': { + id: 'job-a', + name: 'Job A', + adaptor: '@openfn/language-common', + }, + 'job-b': { + id: 'job-b', + name: 'Job B', + adaptor: '@openfn/language-common', + }, + }, + triggers: { + 'trigger-1': { + id: 'trigger-1', + type: 'webhook', + }, + }, + edges: [ + { + id: 'edge-1', + source: 'trigger-1', + target: 'job-a', + }, + ], + }); + + const { store, ydoc, cleanup } = setupWorkflowStoreTest(customYDoc); + + expect(store.isConnected).toBe(true); + + // Verify Y.Doc has the jobs + expect(ydoc.getArray('jobs').length).toBe(2); + expect(ydoc.getArray('triggers').length).toBe(1); + expect(ydoc.getArray('edges').length).toBe(1); + + // Verify store synced the data + const state = store.getSnapshot(); + expect(state.jobs).toHaveLength(2); + expect(state.triggers).toHaveLength(1); + expect(state.edges).toHaveLength(1); + + cleanup(); + }); + + test('provides mock channel and provider', () => { + const { mockChannel, mockProvider, cleanup } = setupWorkflowStoreTest(); + + expect(mockChannel).toBeDefined(); + expect(mockChannel.push).toBeDefined(); + expect(mockChannel.on).toBeDefined(); + expect(mockChannel.off).toBeDefined(); + + expect(mockProvider).toBeDefined(); + expect(mockProvider.channel).toBe(mockChannel); + + cleanup(); + }); + + test('cleanup disconnects store', () => { + const { store, cleanup } = setupWorkflowStoreTest(); + + expect(store.isConnected).toBe(true); + + cleanup(); + + expect(store.isConnected).toBe(false); + }); +}); + +describe('createEmptyWorkflowYDoc', () => { + test('creates Y.Doc with workflow structure', () => { + const ydoc = createEmptyWorkflowYDoc(); + + expect(ydoc.getMap('workflow')).toBeDefined(); + expect(ydoc.getArray('jobs')).toBeDefined(); + expect(ydoc.getArray('triggers')).toBeDefined(); + expect(ydoc.getArray('edges')).toBeDefined(); + expect(ydoc.getMap('positions')).toBeDefined(); + expect(ydoc.getMap('errors')).toBeDefined(); + + // All should be empty + expect(ydoc.getArray('jobs').length).toBe(0); + expect(ydoc.getArray('triggers').length).toBe(0); + expect(ydoc.getArray('edges').length).toBe(0); + expect(ydoc.getMap('positions').size).toBe(0); + expect(ydoc.getMap('errors').size).toBe(0); + }); +}); + +describe('createMinimalWorkflowYDoc', () => { + test('creates Y.Doc with workflow metadata', () => { + const ydoc = createMinimalWorkflowYDoc('wf-123', 'My Workflow', 5); + + const workflowMap = ydoc.getMap('workflow'); + expect(workflowMap.get('id')).toBe('wf-123'); + expect(workflowMap.get('name')).toBe('My Workflow'); + expect(workflowMap.get('lock_version')).toBe(5); + expect(workflowMap.get('deleted_at')).toBe(null); + expect(workflowMap.get('concurrency')).toBe(null); + expect(workflowMap.get('enable_job_logs')).toBe(false); + }); + + test('uses defaults when no arguments provided', () => { + const ydoc = createMinimalWorkflowYDoc(); + + const workflowMap = ydoc.getMap('workflow'); + expect(workflowMap.get('id')).toBe('workflow-test'); + expect(workflowMap.get('name')).toBe('Test Workflow'); + expect(workflowMap.get('lock_version')).toBe(null); + }); +}); diff --git a/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.ts b/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.ts new file mode 100644 index 0000000000..bfd315dae6 --- /dev/null +++ b/assets/test/collaborative-editor/__helpers__/workflowStoreHelpers.ts @@ -0,0 +1,219 @@ +/** + * Workflow Store Test Helpers + * + * Utility functions for testing workflow store functionality. These helpers + * simplify the setup of WorkflowStore instances with Y.Doc and provider + * connections for testing. + * + * Since WorkflowStore is complex and commonly used in tests, these helpers + * consolidate the repetitive Y.Doc + provider initialization logic. + * + * Usage: + * const { store, ydoc, cleanup } = setupWorkflowStoreTest(); + * // ... run test + * cleanup(); + */ + +import type { Channel } from 'phoenix'; +import * as Y from 'yjs'; + +import type { WorkflowStoreInstance } from '../../../js/collaborative-editor/stores/createWorkflowStore'; +import { createWorkflowStore } from '../../../js/collaborative-editor/stores/createWorkflowStore'; +import type { Session } from '../../../js/collaborative-editor/types/session'; + +import { + createMockPhoenixChannel, + createMockPhoenixChannelProvider, + type MockPhoenixChannel, + type MockPhoenixChannelProvider, +} from './channelMocks'; + +/** + * Result of setting up a workflow store test + */ +export interface WorkflowStoreTestSetup { + /** The workflow store instance */ + store: WorkflowStoreInstance; + /** The Y.Doc instance (typed as WorkflowDoc) */ + ydoc: Session.WorkflowDoc; + /** Mock Phoenix channel */ + mockChannel: MockPhoenixChannel; + /** Mock channel provider */ + mockProvider: MockPhoenixChannelProvider & { channel: Channel }; + /** Cleanup function to call after test */ + cleanup: () => void; +} + +/** + * Sets up a workflow store test with Y.Doc and provider connection + * + * This helper creates a WorkflowStore instance, initializes a Y.Doc with + * workflow structure, sets up mock channel and provider, and connects them + * together. It provides a consistent starting point for workflow store tests. + * + * The Y.Doc is initialized with the basic workflow structure (workflow map, + * jobs array, triggers array, edges array, positions map, errors map) but + * all are empty. Use the optional `ydoc` parameter to provide a pre-populated + * Y.Doc created with `createWorkflowYDoc()` from workflowFactory. + * + * @param ydoc - Optional pre-configured Y.Doc (defaults to empty workflow) + * @param topic - Optional channel topic (defaults to "test:workflow") + * @returns Test setup with store, ydoc, mocks, and cleanup function + * + * @example + * // Basic usage with empty workflow + * test("workflow store functionality", () => { + * const { store, ydoc, cleanup } = setupWorkflowStoreTest(); + * + * // Y.Doc is already connected, ready to use + * expect(store.isConnected).toBe(true); + * + * cleanup(); + * }); + * + * @example + * // Usage with pre-populated Y.Doc + * import { createWorkflowYDoc } from "./__helpers__"; + * + * test("workflow with jobs", () => { + * const ydoc = createWorkflowYDoc({ + * jobs: { + * "job-a": { id: "job-a", name: "Job A", adaptor: "@openfn/language-common" } + * } + * }); + * + * const { store, cleanup } = setupWorkflowStoreTest(ydoc); + * + * const state = store.getSnapshot(); + * expect(state.jobs).toHaveLength(1); + * + * cleanup(); + * }); + * + * @example + * // Configuring channel responses + * test("workflow save", async () => { + * const { store, mockChannel, cleanup } = setupWorkflowStoreTest(); + * + * // Configure mock channel for save_workflow + * mockChannel.push = vi.fn().mockReturnValue({ + * receive: (status: string, callback: (response?: any) => void) => { + * if (status === "ok") { + * callback({ saved_at: "2025-01-01", lock_version: 1 }); + * } + * return { receive: () => {} }; + * } + * }); + * + * await store.saveWorkflow(); + * + * cleanup(); + * }); + */ +export function setupWorkflowStoreTest( + ydoc?: Y.Doc, + topic: string = 'test:workflow' +): WorkflowStoreTestSetup { + const store = createWorkflowStore(); + + // Create or use provided Y.Doc + const workflowDoc = (ydoc ?? + createEmptyWorkflowYDoc()) as Session.WorkflowDoc; + + // Create mock channel and provider + const mockChannel = createMockPhoenixChannel(topic); + const mockProvider = createMockPhoenixChannelProvider( + mockChannel + ) as MockPhoenixChannelProvider & { channel: Channel }; + + // Attach the Y.Doc to the provider (required by WorkflowStore) + (mockProvider as any).doc = workflowDoc; + + // Connect store to Y.Doc and provider + store.connect(workflowDoc, mockProvider as any); + + return { + store, + ydoc: workflowDoc, + mockChannel, + mockProvider, + cleanup: () => { + store.disconnect(); + }, + }; +} + +/** + * Creates an empty Y.Doc with workflow structure + * + * Initializes a Y.Doc with the expected workflow structure: + * - workflow map (empty) + * - jobs array (empty) + * - triggers array (empty) + * - edges array (empty) + * - positions map (empty) + * - errors map (empty) + * + * This is used internally by setupWorkflowStoreTest when no custom Y.Doc + * is provided. For tests that need pre-populated workflows, use + * `createWorkflowYDoc()` from workflowFactory instead. + * + * @returns Y.Doc with empty workflow structure + * + * @example + * const ydoc = createEmptyWorkflowYDoc(); + * expect(ydoc.getArray("jobs").length).toBe(0); + */ +export function createEmptyWorkflowYDoc(): Y.Doc { + const ydoc = new Y.Doc(); + + // Initialize workflow map (empty) + ydoc.getMap('workflow'); + + // Initialize arrays (empty) + ydoc.getArray('jobs'); + ydoc.getArray('triggers'); + ydoc.getArray('edges'); + + // Initialize positions map (empty) + ydoc.getMap('positions'); + + // Initialize errors map (empty) + ydoc.getMap('errors'); + + return ydoc; +} + +/** + * Creates a minimal workflow Y.Doc with basic workflow metadata + * + * Useful for tests that need a workflow with an ID and name but no jobs, + * triggers, or edges yet. + * + * @param id - Workflow ID (defaults to "workflow-test") + * @param name - Workflow name (defaults to "Test Workflow") + * @param lockVersion - Lock version (defaults to null for new workflow) + * @returns Y.Doc with workflow metadata + * + * @example + * const ydoc = createMinimalWorkflowYDoc("wf-123", "My Workflow"); + * const workflowMap = ydoc.getMap("workflow"); + * expect(workflowMap.get("id")).toBe("wf-123"); + */ +export function createMinimalWorkflowYDoc( + id: string = 'workflow-test', + name: string = 'Test Workflow', + lockVersion: number | null = null +): Y.Doc { + const ydoc = createEmptyWorkflowYDoc(); + + const workflowMap = ydoc.getMap('workflow'); + workflowMap.set('id', id); + workflowMap.set('name', name); + workflowMap.set('lock_version', lockVersion); + workflowMap.set('deleted_at', null); + workflowMap.set('concurrency', null); + workflowMap.set('enable_job_logs', false); + + return ydoc; +} diff --git a/assets/test/collaborative-editor/components/AdaptorSelectionModal.test.tsx b/assets/test/collaborative-editor/components/AdaptorSelectionModal.test.tsx index e6d9846fbc..b01e538bb8 100644 --- a/assets/test/collaborative-editor/components/AdaptorSelectionModal.test.tsx +++ b/assets/test/collaborative-editor/components/AdaptorSelectionModal.test.tsx @@ -5,8 +5,8 @@ * when creating new job nodes in the workflow canvas. */ +import { KeyboardProvider } from '#/collaborative-editor/keyboard'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { HotkeysProvider } from 'react-hotkeys-hook'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AdaptorSelectionModal } from '../../../js/collaborative-editor/components/AdaptorSelectionModal'; import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider'; @@ -102,11 +102,11 @@ function renderWithProviders( mockStoreContext = createMockStoreContext() ) { return render( - + {ui} - + ); } @@ -281,7 +281,7 @@ describe('AdaptorSelectionModal', () => { const mockContext = createMockStoreContext(); const TestWrapper = ({ isOpen }: { isOpen: boolean }) => ( - + { projectAdaptors={mockProjectAdaptors} /> - + ); const { rerender } = render(); diff --git a/assets/test/collaborative-editor/components/ConfigureAdaptorModal.test.tsx b/assets/test/collaborative-editor/components/ConfigureAdaptorModal.test.tsx index 7b0aba8805..cbcca294a2 100644 --- a/assets/test/collaborative-editor/components/ConfigureAdaptorModal.test.tsx +++ b/assets/test/collaborative-editor/components/ConfigureAdaptorModal.test.tsx @@ -14,24 +14,18 @@ * - Modal close behavior */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - render, - screen, - fireEvent, - waitFor, - within, -} from '@testing-library/react'; +import { KeyboardProvider } from '#/collaborative-editor/keyboard'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { HotkeysProvider } from 'react-hotkeys-hook'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConfigureAdaptorModal } from '../../../js/collaborative-editor/components/ConfigureAdaptorModal'; import { LiveViewActionsProvider } from '../../../js/collaborative-editor/contexts/LiveViewActionsContext'; import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider'; import type { Adaptor } from '../../../js/collaborative-editor/types/adaptor'; import type { - ProjectCredential, KeychainCredential, + ProjectCredential, } from '../../../js/collaborative-editor/types/credential'; // Mock useAdaptorIcons to avoid fetching icon manifest @@ -243,13 +237,13 @@ function renderWithProviders( mockLiveViewActions = createMockLiveViewActions() ) { return render( - + {ui} - + ); } @@ -898,7 +892,7 @@ describe('ConfigureAdaptorModal', () => { // Close modal (simulating user clicking Change button) rerender( - + { /> - + ); // Reopen with new adaptor (simulating selection from AdaptorSelectionModal) rerender( - + { /> - + ); // Should show HTTP adaptor now @@ -986,7 +980,7 @@ describe('ConfigureAdaptorModal', () => { // Close modal (simulating: Close → Adaptor Picker) rerender( - + { /> - + ); // Reopen with HTTP (simulating: Adaptor Picker → Reopen) rerender( - + { /> - + ); // Verify HTTP is shown @@ -1059,24 +1053,24 @@ describe('ConfigureAdaptorModal', () => { // Close modal rerender( - + - + ); // Reopen modal rerender( - + - + ); // Should reset to initial values diff --git a/assets/test/collaborative-editor/components/Header.keyboard.test.tsx b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx new file mode 100644 index 0000000000..15a952ca86 --- /dev/null +++ b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx @@ -0,0 +1,945 @@ +/** + * Header Keyboard Shortcut Tests + * + * Tests for keyboard shortcuts in the Header component: + * - Cmd+S / Ctrl+S (Save Workflow) + * - Cmd+Shift+S / Ctrl+Shift+S (Save & Sync to GitHub) + * + * Testing approach: + * - Library-agnostic (tests user-facing behavior, not implementation) + * - Platform coverage (Mac Cmd and Windows Ctrl) + * - Guard conditions (canSave, repoConnection) + * - Form field support (enableOnFormTags) + */ + +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type React from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { Header } from '../../../js/collaborative-editor/components/Header'; +import { SessionContext } from '../../../js/collaborative-editor/contexts/SessionProvider'; +import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider'; +import { KeyboardProvider } from '../../../js/collaborative-editor/keyboard'; +import type { CreateSessionContextOptions } from '../__helpers__/sessionContextFactory'; +import { simulateStoreProviderWithConnection } from '../__helpers__/storeProviderHelpers'; +import { createMinimalWorkflowYDoc } from '../__helpers__/workflowStoreHelpers'; + +// ============================================================================= +// TEST MOCKS +// ============================================================================= + +// Mock useAdaptorIcons to prevent async fetch warnings +vi.mock('../../../js/workflow-diagram/useAdaptorIcons', () => ({ + default: () => ({}), +})); + +// Mock Tooltip to prevent Radix UI timer-based updates +vi.mock('../../../js/collaborative-editor/components/Tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// ============================================================================= +// TEST HELPERS +// ============================================================================= + +interface WrapperOptions { + permissions?: { can_edit_workflow: boolean; can_run_workflow: boolean }; + latestSnapshotLockVersion?: number; + workflowLockVersion?: number | null; + hasGithubConnection?: boolean; + repoName?: string; + branchName?: string; + workflowDeleted?: boolean; +} + +async function createTestSetup(options: WrapperOptions = {}) { + const { + permissions = { can_edit_workflow: true, can_run_workflow: true }, + latestSnapshotLockVersion = 1, + workflowLockVersion = 1, + hasGithubConnection = false, + repoName = 'openfn/demo', + branchName = 'main', + workflowDeleted = false, + } = options; + + // Create Y.Doc with workflow metadata using helper + const ydoc = createMinimalWorkflowYDoc( + 'test-workflow-123', + 'Test Workflow', + workflowLockVersion + ); + + // Set deleted_at if specified + if (workflowDeleted) { + const workflowMap = ydoc.getMap('workflow'); + workflowMap.set('deleted_at', new Date().toISOString()); + } + + // Build session context options + const sessionContextOptions: CreateSessionContextOptions = { + permissions, + latest_snapshot_lock_version: latestSnapshotLockVersion, + }; + + if (hasGithubConnection) { + sessionContextOptions.project_repo_connection = { + repo: repoName, + branch: branchName, + }; + } + + // Use enhanced helper - THIS HANDLES CONNECTION STATE! + const { stores, sessionStore, cleanup, emitSessionContext } = + await simulateStoreProviderWithConnection( + 'test:room', + { + id: 'user-1', + name: 'Test User', + color: '#ff0000', + }, + { + workflowYDoc: ydoc, + sessionContext: sessionContextOptions, + emitSessionContext: true, + } + ); + + // CRITICAL FIX: Manually emit 'sync' event on provider + // The mock channel doesn't trigger Y.js sync protocol, so provider never emits 'sync' + // We need to manually trigger it so isSynced becomes true + const provider = sessionStore.getProvider(); + if (provider) { + // Emit the 'sync' event with synced=true + (provider as any).emit('sync', [true]); + } + + // Wait a bit for the sync event to propagate + await new Promise(resolve => setTimeout(resolve, 150)); + + // Add spies for keyboard test assertions + const saveWorkflowSpy = vi + .spyOn(stores.workflowStore, 'saveWorkflow') + .mockResolvedValue(undefined); + const openGitHubSyncModalSpy = vi.spyOn( + stores.uiStore, + 'openGitHubSyncModal' + ); + + // Wrapper with KeyboardProvider (keyboard-specific) + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + return { + wrapper, + stores, + sessionStore, + emitSessionContext, + saveWorkflowSpy, + openGitHubSyncModalSpy, + cleanup, + }; +} + +// Helper to render and wait for component to be ready +async function renderAndWaitForReady( + wrapper: React.ComponentType<{ children: React.ReactNode }>, + emitSessionContext: () => void +) { + const result = render( +
+ {[Breadcrumb]} +
, + { wrapper } + ); + + await act(async () => { + emitSessionContext(); + await new Promise(resolve => setTimeout(resolve, 150)); + }); + + await waitFor(() => { + const saveButton = screen.getByTestId('save-workflow-button'); + expect(saveButton).toBeInTheDocument(); + }); + + return result; +} + +// ============================================================================= +// SAVE WORKFLOW KEYBOARD SHORTCUT TESTS (Cmd+S / Ctrl+S) +// ============================================================================= + +describe('Header - Save Workflow (Cmd+S / Ctrl+S)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + // Wait for any pending async updates to settle after test + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + }); + + test('Cmd+S calls saveWorkflow when canSave is true (Mac)', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWaitForReady( + wrapper, + emitSessionContext! + ); + + // Verify the save button is rendered (confirms Header is mounted) + const saveButton = screen.getByTestId('save-workflow-button'); + expect(saveButton).toBeInTheDocument(); + + await user.keyboard('{Meta>}s{/Meta}'); + + await waitFor(() => expect(saveWorkflowSpy).toHaveBeenCalledTimes(1)); + + unmount(); + cleanup(); + }); + + test('Ctrl+S calls saveWorkflow when canSave is true (Windows)', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWaitForReady( + wrapper, + emitSessionContext! + ); + + await user.keyboard('{Control>}s{/Control}'); + + await waitFor(() => expect(saveWorkflowSpy).toHaveBeenCalledTimes(1)); + + unmount(); + cleanup(); + }); + + test('Cmd+S does NOT call saveWorkflow when no edit permission', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: false, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWaitForReady( + wrapper, + emitSessionContext! + ); + + await user.keyboard('{Meta>}s{/Meta}'); + + // Wait to ensure handler doesn't fire + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(saveWorkflowSpy).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Cmd+S does NOT call saveWorkflow when workflow is deleted', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + workflowDeleted: true, + }); + + const { unmount } = await renderAndWaitForReady( + wrapper, + emitSessionContext! + ); + + await user.keyboard('{Meta>}s{/Meta}'); + + // Wait to ensure handler doesn't fire + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(saveWorkflowSpy).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Cmd+S responds to dynamic canSave changes (enable → disable → enable)', async () => { + const user = userEvent.setup(); + + // Start with canSave = true + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWaitForReady( + wrapper, + emitSessionContext! + ); + + // Phase 1: canSave = true, shortcut should work + await user.keyboard('{Meta>}s{/Meta}'); + await waitFor(() => expect(saveWorkflowSpy).toHaveBeenCalledTimes(1)); + + saveWorkflowSpy.mockClear(); + + // Phase 2: Change to canSave = false (simulate permission loss) + await act(async () => { + emitSessionContext!({ + permissions: { can_edit_workflow: false, can_run_workflow: true }, + }); + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + // Shortcut should NOT work + await user.keyboard('{Meta>}s{/Meta}'); + await new Promise(resolve => setTimeout(resolve, 150)); + expect(saveWorkflowSpy).not.toHaveBeenCalled(); + + // Phase 3: Change back to canSave = true + await act(async () => { + emitSessionContext!({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + // Shortcut should work again + await user.keyboard('{Meta>}s{/Meta}'); + await waitFor(() => expect(saveWorkflowSpy).toHaveBeenCalledTimes(1)); + + unmount(); + cleanup(); + }); + + test('Cmd+S does NOT call saveWorkflow when viewing old snapshot', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + workflowLockVersion: 1, + latestSnapshotLockVersion: 2, + }); + + const { unmount } = await renderAndWaitForReady( + wrapper, + emitSessionContext! + ); + + await user.keyboard('{Meta>}s{/Meta}'); + + // Wait to ensure handler doesn't fire + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(saveWorkflowSpy).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Cmd+S works in input fields (enableOnFormTags)', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = render( + <> + +
+ {[Breadcrumb]} +
+ , + { wrapper } + ); + + await act(async () => { + emitSessionContext!(); + await new Promise(resolve => setTimeout(resolve, 150)); + }); + + await waitFor(() => { + expect(screen.getByTestId('save-workflow-button')).toBeInTheDocument(); + }); + + const input = screen.getByTestId('test-input'); + await user.click(input); + + await user.keyboard('{Meta>}s{/Meta}'); + + await waitFor(() => expect(saveWorkflowSpy).toHaveBeenCalled()); + + unmount(); + cleanup(); + }); + + test('Cmd+S works in textarea (enableOnFormTags)', async () => { + const user = userEvent.setup(); + const { wrapper, emitSessionContext, saveWorkflowSpy, cleanup } = + await createTestSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = render( + <> +