diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx index 6c4a9d95ca0..6b4863071bd 100644 --- a/assets/js/collaborative-editor/CollaborativeEditor.tsx +++ b/assets/js/collaborative-editor/CollaborativeEditor.tsx @@ -12,6 +12,7 @@ import type { MonacoHandle } from './components/CollaborativeMonaco'; import { Header } from './components/Header'; import { LandingScreen } from './components/LandingScreen'; import { LoadingBoundary } from './components/LoadingBoundary'; +import { TemplateBrowserModalWrapper } from './components/TemplateBrowserModalWrapper'; import { Toaster } from './components/ui/Toaster'; import { VersionDebugLogger } from './components/VersionDebugLogger'; import { VersionDropdown } from './components/VersionDropdown'; @@ -183,8 +184,12 @@ function LandingScreenWrapper({ aiAssistantEnabled: boolean; }) { const showLandingScreen = useShowLandingScreen(); - const { openYAMLImportModal, dismissLandingScreen, openAIAssistantPanel } = - useUICommands(); + const { + openYAMLImportModal, + openTemplateBrowserModal, + dismissLandingScreen, + openAIAssistantPanel, + } = useUICommands(); if (!showLandingScreen) return null; @@ -197,10 +202,11 @@ function LandingScreenWrapper({ openAIAssistantPanel(prompt); }} onBuildFromScratch={() => {}} - onBrowseTemplates={() => {}} + onBrowseTemplates={openTemplateBrowserModal} onImportYAML={openYAMLImportModal} /> + ); } diff --git a/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx b/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx new file mode 100644 index 00000000000..0537d3928ff --- /dev/null +++ b/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx @@ -0,0 +1,209 @@ +import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; + +import { cn } from '#/utils/cn'; + +import type { + BaseTemplate, + Template, + WorkflowTemplate, +} from '../types/template'; + +function matchesQuery( + t: { name: string; description: string | null; tags: string[] }, + q: string +): boolean { + return ( + t.name.toLowerCase().includes(q) || + (t.description?.toLowerCase().includes(q) ?? false) || + t.tags.some(tag => tag.toLowerCase().includes(q)) + ); +} + +export function filterTemplates( + templates: WorkflowTemplate[], + q: string +): WorkflowTemplate[] { + if (!q) return templates; + return templates.filter(t => matchesQuery(t, q)); +} + +export interface TemplateBrowserModalProps { + isOpen: boolean; + onClose: () => void; + templates: Template[]; + loading?: boolean; + isSaving?: boolean; + onSelect: (template: Template) => void; + searchQuery: string; + onSearchChange: (query: string) => void; +} + +export function TemplateBrowserModal({ + isOpen, + onClose, + templates, + loading = false, + isSaving = false, + onSelect, + searchQuery, + onSearchChange, +}: TemplateBrowserModalProps) { + const baseTemplates = templates.filter( + (t): t is BaseTemplate => (t as BaseTemplate).isBase === true + ); + const userTemplates = templates.filter( + (t): t is WorkflowTemplate => (t as BaseTemplate).isBase !== true + ); + const q = searchQuery.trim().toLowerCase(); + const filteredUserTemplates = filterTemplates(userTemplates, q); + const anyBaseTemplateMatches = + q.length > 0 && baseTemplates.some(t => matchesQuery(t, q)); + + let cols = 1; + if (templates.length > 6) cols = 3; + else if (templates.length > 3) cols = 2; + + return ( + + +
+ + {/* Header */} +
+

Templates

+ +
+ + {/* Search bar — fixed, does not scroll */} +
+
+ + + + onSearchChange(e.target.value)} + disabled={loading} + className="w-full rounded-md border border-gray-200 py-2 pl-9 pr-3 text-sm + text-gray-900 placeholder:text-gray-400 + focus:outline-none focus-visible:ring-1 focus-visible:border-gray-300 focus-visible:ring-gray-300 + disabled:opacity-50" + /> +
+
+ + {/* Content — scrollable, fills remaining panel height */} +
+ {loading ? ( +

+ Loading templates... +

+ ) : ( +
+ {/* Base templates are always shown unfiltered — intentional */} + {baseTemplates.map(template => ( + onSelect(template)} + /> + ))} + {filteredUserTemplates.map(template => ( + onSelect(template)} + /> + ))} + {userTemplates.length > 0 && + filteredUserTemplates.length === 0 && + searchQuery.trim() && + !anyBaseTemplateMatches && ( +

+ No saved templates match your search. +

+ )} +
+ )} +
+
+
+
+ ); +} + +interface TemplateSelectCardProps { + template: Template; + disabled: boolean; + onClick: () => void; +} + +function TemplateSelectCard({ + template, + disabled, + onClick, +}: TemplateSelectCardProps) { + return ( + + ); +} diff --git a/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx b/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx new file mode 100644 index 00000000000..3b1a9f5c11f --- /dev/null +++ b/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; + +import { parseWorkflowYAML, convertWorkflowSpecToState } from '../../yaml/util'; +import { fetchTemplates } from '../api/templates'; +import { BASE_TEMPLATES } from '../constants/baseTemplates'; +import { useActionLock } from '../hooks/useActionLock'; +import { useSession } from '../hooks/useSession'; +import { useShowTemplateBrowserModal, useUICommands } from '../hooks/useUI'; +import { useWorkflowActions } from '../hooks/useWorkflow'; +import { useKeyboardShortcut } from '../keyboard'; +import { notifications } from '../lib/notifications'; +import type { Template } from '../types/template'; + +import { TemplateBrowserModal } from './TemplateBrowserModal'; + +export function TemplateBrowserModalWrapper() { + const isOpen = useShowTemplateBrowserModal(); + const { closeTemplateBrowserModal, dismissLandingScreen } = useUICommands(); + const provider = useSession(s => s.provider); + const channel = provider?.channel; + const { importWorkflow, saveWorkflow } = useWorkflowActions(); + + const [templates, setTemplates] = useState(BASE_TEMPLATES); + const [loading, setLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + useKeyboardShortcut('Escape', closeTemplateBrowserModal, 100, { + enabled: isOpen, + }); + + // Lazy fetch — only when modal opens, not on every /new load + useEffect(() => { + if (!isOpen) return; + setSearchQuery(''); + if (!channel) return; + + const load = async () => { + setLoading(true); + try { + const userTemplates = await fetchTemplates(channel); + setTemplates([...BASE_TEMPLATES, ...userTemplates]); + } catch { + notifications.alert({ + title: 'Failed to load templates', + description: 'Please check your connection and try again.', + }); + } finally { + setLoading(false); + } + }; + + void load(); + }, [isOpen, channel]); + + const { run: handleSelect, isPending: isSaving } = useActionLock( + async (template: Template) => { + try { + const spec = parseWorkflowYAML(template.code); + const state = convertWorkflowSpecToState(spec); + await importWorkflow(state); + const saved = await saveWorkflow({ silent: true }); + if (!saved) { + notifications.alert({ + title: 'Not connected', + description: 'Connect to the server before creating a workflow.', + }); + return; + } + closeTemplateBrowserModal(); + dismissLandingScreen(); + } catch { + notifications.alert({ + title: 'Failed to create workflow', + description: 'Please check your connection and try again.', + }); + } + } + ); + + return ( + void handleSelect(template)} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + /> + ); +} diff --git a/assets/js/collaborative-editor/contexts/LiveViewActionsContext.tsx b/assets/js/collaborative-editor/contexts/LiveViewActionsContext.tsx index 4f9dee209e5..421a15914b6 100644 --- a/assets/js/collaborative-editor/contexts/LiveViewActionsContext.tsx +++ b/assets/js/collaborative-editor/contexts/LiveViewActionsContext.tsx @@ -11,7 +11,7 @@ interface LiveViewActions { name: string, callback: (payload: unknown) => void ) => () => void; - navigate: (path: string) => void; + navigate: (path: string, options?: { replace?: boolean }) => void; } const LiveViewActionsContext = createContext(null); diff --git a/assets/js/collaborative-editor/hooks/useActionLock.ts b/assets/js/collaborative-editor/hooks/useActionLock.ts new file mode 100644 index 00000000000..e15d285c12c --- /dev/null +++ b/assets/js/collaborative-editor/hooks/useActionLock.ts @@ -0,0 +1,34 @@ +import { useCallback, useRef, useState } from 'react'; + +/** + * Guards an async action against re-entrant calls: `run` invokes `fn` unless a + * previous invocation is still in flight, and `isPending` mirrors the in-flight + * state for disabling UI. `fn` is expected to handle its own errors; rejections + * pass through to the caller, and the lock is always released. + * + * Not for locks that outlive the promise (e.g. useRunRetry's + * WebSocket-confirmed submit) — this releases when `fn` settles. + */ +export function useActionLock( + fn: (...args: A) => Promise +): { run: (...args: A) => Promise; isPending: boolean } { + const fnRef = useRef(fn); + fnRef.current = fn; + + const pendingRef = useRef(false); + const [isPending, setIsPending] = useState(false); + + const run = useCallback(async (...args: A): Promise => { + if (pendingRef.current) return undefined; + pendingRef.current = true; + setIsPending(true); + try { + return await fnRef.current(...args); + } finally { + pendingRef.current = false; + setIsPending(false); + } + }, []); + + return { run, isPending }; +} diff --git a/assets/js/collaborative-editor/hooks/useUI.ts b/assets/js/collaborative-editor/hooks/useUI.ts index 4974e7b403e..04075bc7a22 100644 --- a/assets/js/collaborative-editor/hooks/useUI.ts +++ b/assets/js/collaborative-editor/hooks/useUI.ts @@ -65,6 +65,8 @@ export const useUICommands = () => { dismissLandingScreen: uiStore.dismissLandingScreen, openYAMLImportModal: uiStore.openYAMLImportModal, closeYAMLImportModal: uiStore.closeYAMLImportModal, + openTemplateBrowserModal: uiStore.openTemplateBrowserModal, + closeTemplateBrowserModal: uiStore.closeTemplateBrowserModal, // Import panel write commands setImportState: uiStore.setImportState, setImportYamlContent: uiStore.setImportYamlContent, @@ -164,6 +166,20 @@ export const useShowYAMLImportModal = (): boolean => { return useSyncExternalStore(uiStore.subscribe, selectShowYAMLImportModal); }; +/** + * Hook to check if the template browser modal is open + */ +export const useShowTemplateBrowserModal = (): boolean => { + const uiStore = useUIStore(); + const selectShowTemplateBrowserModal = uiStore.withSelector( + state => state.showTemplateBrowserModal + ); + return useSyncExternalStore( + uiStore.subscribe, + selectShowTemplateBrowserModal + ); +}; + /** * Hook to get the entire template panel state * Returns properly typed state - no type assertions needed diff --git a/assets/js/collaborative-editor/hooks/useWorkflow.tsx b/assets/js/collaborative-editor/hooks/useWorkflow.tsx index 30952149de3..c90525fc55b 100644 --- a/assets/js/collaborative-editor/hooks/useWorkflow.tsx +++ b/assets/js/collaborative-editor/hooks/useWorkflow.tsx @@ -38,6 +38,7 @@ import React, { import { useURLState } from '#/react/lib/use-url-state'; +import { useLiveViewActions } from '../contexts/LiveViewActionsContext'; import { StoreContext } from '../contexts/StoreProvider'; import { formatChannelErrorMessage, @@ -351,6 +352,7 @@ export const useNodeSelection = () => { export const useWorkflowActions = () => { const store = useWorkflowStoreContext(); const context = useContext(StoreContext); + const { navigate } = useLiveViewActions(); if (!context) { throw new Error('useWorkflowActions must be used within StoreProvider'); @@ -425,7 +427,7 @@ export const useWorkflowActions = () => { searchParams.delete('search'); // Clear template search const queryString = searchParams.toString(); const newUrl = `/projects/${projectId}/w/${workflowId}${queryString ? `?${queryString}` : ''}`; - window.history.replaceState(null, '', newUrl); + navigate(newUrl, { replace: true }); // Clear template state in UI store uiStore.selectTemplate(null); @@ -451,8 +453,10 @@ export const useWorkflowActions = () => { // Helper: Handle save errors with appropriate notifications const handleSaveError = ( error: unknown, - retrySaveWorkflow: () => Promise + retrySaveWorkflow: () => Promise, + silent?: boolean ) => { + if (silent) return; // Format channel errors into user-friendly messages if (isChannelRequestError(error)) { error.message = formatChannelErrorMessage({ @@ -519,7 +523,7 @@ export const useWorkflowActions = () => { handleSaveSuccess(response, options?.silent); return response; } catch (error) { - handleSaveError(error, wrappedSaveWorkflow); + handleSaveError(error, wrappedSaveWorkflow, options?.silent); // Re-throw error for any upstream error handling throw error; } diff --git a/assets/js/collaborative-editor/stores/createUIStore.ts b/assets/js/collaborative-editor/stores/createUIStore.ts index 8db26c4aeab..51a7a2274f9 100644 --- a/assets/js/collaborative-editor/stores/createUIStore.ts +++ b/assets/js/collaborative-editor/stores/createUIStore.ts @@ -132,6 +132,7 @@ export const createUIStore = (isNewWorkflow: boolean = false): UIStore => { createWorkflowPanelCollapsed, showLandingScreen: true, showYAMLImportModal: false, + showTemplateBrowserModal: false, templatePanel: { templates: [], loading: false, @@ -358,6 +359,20 @@ export const createUIStore = (isNewWorkflow: boolean = false): UIStore => { notify('closeYAMLImportModal'); }; + const openTemplateBrowserModal = () => { + state = produce(state, draft => { + draft.showTemplateBrowserModal = true; + }); + notify('openTemplateBrowserModal'); + }; + + const closeTemplateBrowserModal = () => { + state = produce(state, draft => { + draft.showTemplateBrowserModal = false; + }); + notify('closeTemplateBrowserModal'); + }; + devtools.connect(); // =========================================================================== @@ -394,6 +409,8 @@ export const createUIStore = (isNewWorkflow: boolean = false): UIStore => { dismissLandingScreen, openYAMLImportModal, closeYAMLImportModal, + openTemplateBrowserModal, + closeTemplateBrowserModal, }; }; diff --git a/assets/js/collaborative-editor/types/ui.ts b/assets/js/collaborative-editor/types/ui.ts index e3215770536..54e3ea17f35 100644 --- a/assets/js/collaborative-editor/types/ui.ts +++ b/assets/js/collaborative-editor/types/ui.ts @@ -50,6 +50,9 @@ export interface UIState { /** Whether the YAML import modal is open */ showYAMLImportModal: boolean; + /** Whether the template browser modal is open */ + showTemplateBrowserModal: boolean; + /** Template panel state */ templatePanel: { templates: WorkflowTemplate[]; @@ -115,6 +118,12 @@ export interface UICommands { /** Close the YAML import modal and reset import panel content */ closeYAMLImportModal: () => void; + /** Open the template browser modal */ + openTemplateBrowserModal: () => void; + + /** Close the template browser modal */ + closeTemplateBrowserModal: () => void; + /** Set templates list */ setTemplates: (templates: WorkflowTemplate[]) => void; diff --git a/assets/js/react/hooks/heex-react-component.tsx b/assets/js/react/hooks/heex-react-component.tsx index 81837c95ccf..bcba3de9ad8 100644 --- a/assets/js/react/hooks/heex-react-component.tsx +++ b/assets/js/react/hooks/heex-react-component.tsx @@ -75,10 +75,11 @@ export const HeexReactComponent = { pushEventTo: this.pushEventTo.bind(this, this.el), el: this.el, containerEl: this._containerEl, - navigate: path => { + navigate: (path, options) => { + const replace = options?.replace ?? false; this.liveSocket.execJS( this.el, - '[["patch",{"replace":false,"href":"' + path + '"}]]' + JSON.stringify([['patch', { replace, href: path }]]) ); }, }, diff --git a/assets/js/react/hooks/react-component.tsx b/assets/js/react/hooks/react-component.tsx index 005ad5e4e76..9a66010bcbb 100644 --- a/assets/js/react/hooks/react-component.tsx +++ b/assets/js/react/hooks/react-component.tsx @@ -70,10 +70,11 @@ export const ReactComponent = { pushEventTo: this.pushEventTo.bind(this, this.el), el: this.el, containerEl: this._containerEl, - navigate: path => { + navigate: (path, options) => { + const replace = options?.replace ?? false; this.liveSocket.execJS( this.el, - '[["patch",{"replace":false,"href":"' + path + '"}]]' + JSON.stringify([['patch', { replace, href: path }]]) ); }, }, diff --git a/assets/js/react/lib/with-props.tsx b/assets/js/react/lib/with-props.tsx index 8bc1a829fe6..54529fda464 100644 --- a/assets/js/react/lib/with-props.tsx +++ b/assets/js/react/lib/with-props.tsx @@ -21,7 +21,7 @@ interface ActionProps { ) => () => void; el: HTMLElement; containerEl: HTMLElement; - navigate: (path: string) => void; + navigate: (path: string, options?: { replace?: boolean }) => void; } export type WithActionProps> = diff --git a/assets/test/collaborative-editor/__helpers__/triggerInspectorHelpers.tsx b/assets/test/collaborative-editor/__helpers__/triggerInspectorHelpers.tsx index 687b62ad012..f3f1f98eed3 100644 --- a/assets/test/collaborative-editor/__helpers__/triggerInspectorHelpers.tsx +++ b/assets/test/collaborative-editor/__helpers__/triggerInspectorHelpers.tsx @@ -59,7 +59,8 @@ export interface TriggerTestHarnessOptions { workflowStore?: WorkflowStoreInstance; /** * LiveView actions to expose through LiveViewActionsProvider. - * When omitted the provider is NOT added — only TriggerEditWizard tests need it. + * When omitted, a default set of `vi.fn()` mocks is used — the provider is + * always present since useWorkflowActions() requires it unconditionally. */ liveViewActions?: { pushEvent: ReturnType; @@ -176,25 +177,22 @@ export async function createTriggerTestHarness( } as unknown as StoreContextValue; // 7. Wrapper component. - const wrapper = ({ children }: { children: React.ReactNode }) => { - const inner = ( - + const resolvedLiveViewActions = liveViewActions ?? { + pushEvent: vi.fn(), + pushEventTo: vi.fn(), + handleEvent: vi.fn(() => vi.fn()), + navigate: vi.fn(), + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} - - ); - - if (liveViewActions) { - return ( - - {inner} - - ); - } - - return inner; - }; + + + ); return { wrapper, sessionStore, sessionContextStore, sessionChannel }; } diff --git a/assets/test/collaborative-editor/components/GitHubSyncModal.test.tsx b/assets/test/collaborative-editor/components/GitHubSyncModal.test.tsx index 9ee9bede0c6..fa76814848a 100644 --- a/assets/test/collaborative-editor/components/GitHubSyncModal.test.tsx +++ b/assets/test/collaborative-editor/components/GitHubSyncModal.test.tsx @@ -16,6 +16,7 @@ import { describe, expect, test, vi } from 'vitest'; import * as Y from 'yjs'; import { GitHubSyncModal } from '../../../js/collaborative-editor/components/GitHubSyncModal'; +import { LiveViewActionsProvider } from '../../../js/collaborative-editor/contexts/LiveViewActionsContext'; import { SessionContext } from '../../../js/collaborative-editor/contexts/SessionProvider'; import type { StoreContextValue } from '../../../js/collaborative-editor/contexts/StoreProvider'; import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider'; @@ -117,11 +118,20 @@ function createTestSetup(options: WrapperOptions = {}) { uiStore, }; + const mockLiveViewActions = { + pushEvent: vi.fn(), + pushEventTo: vi.fn(), + handleEvent: vi.fn(() => vi.fn()), + navigate: vi.fn(), + }; + const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + + + {children} + + ); diff --git a/assets/test/collaborative-editor/components/Header.keyboard.test.tsx b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx index 03eb8f07bf6..dbac50096b0 100644 --- a/assets/test/collaborative-editor/components/Header.keyboard.test.tsx +++ b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx @@ -20,6 +20,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { RunDetail } from '../../../js/collaborative-editor/types/history'; import { Header } from '../../../js/collaborative-editor/components/Header'; +import { LiveViewActionsProvider } from '../../../js/collaborative-editor/contexts/LiveViewActionsContext'; import { SessionContext } from '../../../js/collaborative-editor/contexts/SessionProvider'; import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider'; import { KeyboardProvider } from '../../../js/collaborative-editor/keyboard'; @@ -184,11 +185,22 @@ async function createTestSetup(options: WrapperOptions = {}) { 'openGitHubSyncModal' ); + const mockLiveViewActions = { + pushEvent: vi.fn(), + pushEventTo: vi.fn(), + handleEvent: vi.fn(() => vi.fn()), + navigate: vi.fn(), + }; + // Wrapper with KeyboardProvider (keyboard-specific) const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + + {children} + + ); @@ -1063,10 +1075,21 @@ async function createRunSetup( vi.spyOn(stores.workflowStore, 'saveWorkflow').mockResolvedValue(null); + const mockLiveViewActions = { + pushEvent: vi.fn(), + pushEventTo: vi.fn(), + handleEvent: vi.fn(() => vi.fn()), + navigate: vi.fn(), + }; + const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + + {children} + + ); diff --git a/assets/test/collaborative-editor/components/Header.test.tsx b/assets/test/collaborative-editor/components/Header.test.tsx index 561a07e967a..801f26711db 100644 --- a/assets/test/collaborative-editor/components/Header.test.tsx +++ b/assets/test/collaborative-editor/components/Header.test.tsx @@ -12,6 +12,7 @@ import { describe, expect, test, vi } from 'vitest'; import * as Y from 'yjs'; import { Header } from '../../../js/collaborative-editor/components/Header'; +import { LiveViewActionsProvider } from '../../../js/collaborative-editor/contexts/LiveViewActionsContext'; import { SessionContext } from '../../../js/collaborative-editor/contexts/SessionProvider'; import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider'; import { KeyboardProvider } from '../../../js/collaborative-editor/keyboard'; @@ -154,11 +155,22 @@ async function createTestSetup(options: WrapperOptions = {}) { } } + const mockLiveViewActions = { + pushEvent: vi.fn(), + pushEventTo: vi.fn(), + handleEvent: vi.fn(() => vi.fn()), + navigate: vi.fn(), + }; + // Create wrapper (still needed for React context) const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + + {children} + + ); diff --git a/assets/test/collaborative-editor/components/TemplateDetailsCard.test.tsx b/assets/test/collaborative-editor/components/TemplateDetailsCard.test.tsx index 6c9ddefa77a..0bcfae71422 100644 --- a/assets/test/collaborative-editor/components/TemplateDetailsCard.test.tsx +++ b/assets/test/collaborative-editor/components/TemplateDetailsCard.test.tsx @@ -7,8 +7,9 @@ * - Description fallback */ -import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + import { TemplateDetailsCard } from '../../../js/collaborative-editor/components/TemplateDetailsCard'; import type { Template } from '../../../js/collaborative-editor/types/template'; diff --git a/assets/test/collaborative-editor/components/inspector/EdgeForm.test.tsx b/assets/test/collaborative-editor/components/inspector/EdgeForm.test.tsx index f6b58fca1b2..ead56b5b171 100644 --- a/assets/test/collaborative-editor/components/inspector/EdgeForm.test.tsx +++ b/assets/test/collaborative-editor/components/inspector/EdgeForm.test.tsx @@ -10,9 +10,10 @@ import { render, screen, waitFor } from '@testing-library/react'; import type React from 'react'; import { act } from 'react'; -import { beforeEach, describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { EdgeForm } from '../../../../js/collaborative-editor/components/inspector/EdgeForm'; +import { LiveViewActionsProvider } from '../../../../js/collaborative-editor/contexts/LiveViewActionsContext'; import { SessionContext } from '../../../../js/collaborative-editor/contexts/SessionProvider'; import type { StoreContextValue } from '../../../../js/collaborative-editor/contexts/StoreProvider'; import { StoreContext } from '../../../../js/collaborative-editor/contexts/StoreProvider'; @@ -82,11 +83,20 @@ function createWrapper( isNewWorkflow: false, }; + const mockLiveViewActions = { + pushEvent: vi.fn(), + pushEventTo: vi.fn(), + handleEvent: vi.fn(() => vi.fn()), + navigate: vi.fn(), + }; + return ({ children }: { children: React.ReactNode }) => ( - - {children} - + + + {children} + + ); } diff --git a/assets/test/collaborative-editor/components/inspector/trigger/useTriggerDraft.test.tsx b/assets/test/collaborative-editor/components/inspector/trigger/useTriggerDraft.test.tsx index 7a4fd3efbdb..eba8082a6e7 100644 --- a/assets/test/collaborative-editor/components/inspector/trigger/useTriggerDraft.test.tsx +++ b/assets/test/collaborative-editor/components/inspector/trigger/useTriggerDraft.test.tsx @@ -12,6 +12,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import * as Y from 'yjs'; import { useTriggerDraft } from '../../../../../js/collaborative-editor/components/inspector/trigger/useTriggerDraft'; +import { LiveViewActionsProvider } from '../../../../../js/collaborative-editor/contexts/LiveViewActionsContext'; import type { StoreContextValue } from '../../../../../js/collaborative-editor/contexts/StoreProvider'; import { StoreContext } from '../../../../../js/collaborative-editor/contexts/StoreProvider'; import { createSessionContextStore } from '../../../../../js/collaborative-editor/stores/createSessionContextStore'; @@ -58,8 +59,19 @@ function createWrapper( uiStore: createUIStore(), } as unknown as StoreContextValue; + const mockLiveViewActions = { + pushEvent: vi.fn(), + pushEventTo: vi.fn(), + handleEvent: vi.fn(() => vi.fn()), + navigate: vi.fn(), + }; + return ({ children }: { children: React.ReactNode }) => ( - {children} + + + {children} + + ); } diff --git a/assets/test/collaborative-editor/hooks/useActionLock.test.ts b/assets/test/collaborative-editor/hooks/useActionLock.test.ts new file mode 100644 index 00000000000..b7b90fe6051 --- /dev/null +++ b/assets/test/collaborative-editor/hooks/useActionLock.test.ts @@ -0,0 +1,131 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; + +import { useActionLock } from '#/collaborative-editor/hooks/useActionLock'; + +function deferred() { + let deferredResolve!: (value: T) => void; + let deferredReject!: (reason?: unknown) => void; + const promise = new Promise((resolve, reject) => { + deferredResolve = resolve; + deferredReject = reject; + }); + return { promise, resolve: deferredResolve, reject: deferredReject }; +} + +describe('useActionLock', () => { + it('starts with isPending false', () => { + const fn = vi.fn(async () => {}); + const { result } = renderHook(() => useActionLock(fn)); + expect(result.current.isPending).toBe(false); + }); + + it('invokes fn and sets isPending while in flight', async () => { + const { promise, resolve } = deferred(); + const fn = vi.fn(() => promise); + const { result } = renderHook(() => useActionLock(fn)); + + let runPromise!: Promise; + act(() => { + runPromise = result.current.run(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(result.current.isPending).toBe(true); + + await act(async () => { + resolve(); + await runPromise; + }); + + expect(result.current.isPending).toBe(false); + }); + + it('ignores re-entrant calls while a previous call is in flight', async () => { + const { promise, resolve } = deferred(); + const fn = vi.fn(() => promise); + const { result } = renderHook(() => useActionLock(fn)); + + let first!: Promise; + let second!: Promise; + act(() => { + first = result.current.run(); + second = result.current.run(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + + await act(async () => { + resolve(); + await Promise.all([first, second]); + }); + + expect(await second).toBeUndefined(); + }); + + it('allows a new call once the previous one settles', async () => { + const fn = vi.fn(async () => {}); + const { result } = renderHook(() => useActionLock(fn)); + + await act(async () => { + await result.current.run(); + }); + await act(async () => { + await result.current.run(); + }); + + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('releases the lock and rethrows when fn rejects', async () => { + const error = new Error('boom'); + const fn = vi.fn().mockRejectedValueOnce(error); + const { result } = renderHook(() => useActionLock(fn)); + + await act(async () => { + await expect(result.current.run()).rejects.toThrow('boom'); + }); + + expect(result.current.isPending).toBe(false); + + fn.mockResolvedValueOnce(undefined); + await act(async () => { + await result.current.run(); + }); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('passes through arguments and resolved value', async () => { + const fn = vi.fn((a: number, b: number) => Promise.resolve(a + b)); + const { result } = renderHook(() => useActionLock(fn)); + + let value: number | undefined; + await act(async () => { + value = await result.current.run(2, 3); + }); + + expect(fn).toHaveBeenCalledWith(2, 3); + expect(value).toBe(5); + }); + + it('uses the latest fn reference without changing run identity', async () => { + const fnA = vi.fn(() => Promise.resolve('a')); + const fnB = vi.fn(() => Promise.resolve('b')); + const { result, rerender } = renderHook(({ fn }) => useActionLock(fn), { + initialProps: { fn: fnA }, + }); + + const runRef = result.current.run; + rerender({ fn: fnB }); + expect(result.current.run).toBe(runRef); + + let value: string | undefined; + await act(async () => { + value = await result.current.run(); + }); + + expect(fnA).not.toHaveBeenCalled(); + expect(fnB).toHaveBeenCalledTimes(1); + expect(value).toBe('b'); + }); +}); diff --git a/assets/test/collaborative-editor/hooks/useValidation.integration.test.tsx b/assets/test/collaborative-editor/hooks/useValidation.integration.test.tsx index 7bb425ada64..19178c6888b 100644 --- a/assets/test/collaborative-editor/hooks/useValidation.integration.test.tsx +++ b/assets/test/collaborative-editor/hooks/useValidation.integration.test.tsx @@ -13,8 +13,10 @@ import type React from 'react'; import { act } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LiveViewActionsProvider } from '#/collaborative-editor/contexts/LiveViewActionsContext'; import { StoreContext } from '#/collaborative-editor/contexts/StoreProvider'; import { useValidation } from '#/collaborative-editor/hooks/useValidation'; + import { createMinimalWorkflowYDoc, createWorkflowYDoc, @@ -60,10 +62,19 @@ describe('useValidation - Integration', () => { awarenessStore: {} as any, }; + const mockLiveViewActions = { + pushEvent: vi.fn(), + pushEventTo: vi.fn(), + handleEvent: vi.fn(() => vi.fn()), + navigate: vi.fn(), + }; + return ({ children }: { children: React.ReactNode }) => ( - - {children} - + + + {children} + + ); } diff --git a/assets/test/collaborative-editor/utils/filterTemplates.test.ts b/assets/test/collaborative-editor/utils/filterTemplates.test.ts new file mode 100644 index 00000000000..152c353a7df --- /dev/null +++ b/assets/test/collaborative-editor/utils/filterTemplates.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { filterTemplates } from '#/collaborative-editor/components/TemplateBrowserModal'; +import type { WorkflowTemplate } from '#/collaborative-editor/types/template'; + +const make = (overrides: Partial = {}): WorkflowTemplate => ({ + id: 'template-1', + name: 'My Template', + description: null, + code: '', + positions: null, + tags: [], + workflow_id: null, + ...overrides, +}); + +describe('filterTemplates', () => { + it('returns all templates when query is empty', () => { + const templates = [make({ id: '1' }), make({ id: '2' })]; + expect(filterTemplates(templates, '')).toEqual(templates); + }); + + it('matches on name', () => { + const match = make({ id: '1', name: 'DHIS2 to Postgres' }); + const noMatch = make({ id: '2', name: 'Kobo to Google Sheets' }); + expect(filterTemplates([match, noMatch], 'dhis2')).toEqual([match]); + }); + + it('matches on description', () => { + const match = make({ + id: '1', + description: 'Syncs patient records from OpenMRS', + }); + const noMatch = make({ id: '2', description: 'Fetches survey data' }); + expect(filterTemplates([match, noMatch], 'openmrs')).toEqual([match]); + }); + + it('matches on tags', () => { + const match = make({ id: '1', tags: ['webhook', 'kobo'] }); + const noMatch = make({ id: '2', tags: ['cron', 'http'] }); + expect(filterTemplates([match, noMatch], 'kobo')).toEqual([match]); + }); + + it('matches case-insensitively (caller normalizes to lowercase)', () => { + const template = make({ name: 'OpenMRS Integration' }); + expect(filterTemplates([template], 'openmrs')).toEqual([template]); + expect(filterTemplates([template], 'openm')).toEqual([template]); + }); + + it('returns empty array when nothing matches', () => { + const templates = [make({ name: 'DHIS2' }), make({ name: 'Kobo' })]; + expect(filterTemplates(templates, 'salesforce')).toEqual([]); + }); + + it('matches if any field matches (name OR description OR tags)', () => { + const template = make({ + name: 'My Workflow', + description: 'Pulls from DHIS2', + tags: ['scheduled'], + }); + expect(filterTemplates([template], 'dhis2')).toEqual([template]); + expect(filterTemplates([template], 'my workflow')).toEqual([template]); + expect(filterTemplates([template], 'scheduled')).toEqual([template]); + }); + + it('handles null description without throwing', () => { + const template = make({ description: null, name: 'Safe Template' }); + expect(() => filterTemplates([template], 'safe')).not.toThrow(); + expect(filterTemplates([template], 'safe')).toEqual([template]); + }); +}); diff --git a/lib/lightning_web/live/workflow_live/dashboard_components.ex b/lib/lightning_web/live/workflow_live/dashboard_components.ex index e5264272c5e..c03f21a116a 100644 --- a/lib/lightning_web/live/workflow_live/dashboard_components.ex +++ b/lib/lightning_web/live/workflow_live/dashboard_components.ex @@ -354,7 +354,7 @@ defmodule LightningWeb.WorkflowLive.DashboardComponents do tooltip={@tooltip} phx-click={ if !@disabled do - JS.navigate(~p"/projects/#{@project_id}/w/new?method=template") + JS.navigate(~p"/projects/#{@project_id}/w/new") end } class="col-span-1 w-full" diff --git a/lib/lightning_web/live/workflow_live/helpers.ex b/lib/lightning_web/live/workflow_live/helpers.ex index 4f32430eb7f..6758129bed7 100644 --- a/lib/lightning_web/live/workflow_live/helpers.ex +++ b/lib/lightning_web/live/workflow_live/helpers.ex @@ -404,7 +404,7 @@ defmodule LightningWeb.WorkflowLive.Helpers do iex> collaborative_editor_url(%{ ...> "project_id" => "proj-1" ...> }, :new) - "/projects/proj-1/w/new?method=template" + "/projects/proj-1/w/new" # With multiple query params iex> collaborative_editor_url(%{ @@ -494,7 +494,7 @@ defmodule LightningWeb.WorkflowLive.Helpers do end defp collaborative_base_url(%{"project_id" => project_id}, :new) do - "/projects/#{project_id}/w/new?method=template" + "/projects/#{project_id}/w/new" end defp collaborative_base_url(%{"id" => id, "project_id" => project_id}, :edit) do diff --git a/test/lightning_web/live/workflow_live/helpers_test.exs b/test/lightning_web/live/workflow_live/helpers_test.exs index 25d8bf02395..08660184c89 100644 --- a/test/lightning_web/live/workflow_live/helpers_test.exs +++ b/test/lightning_web/live/workflow_live/helpers_test.exs @@ -10,7 +10,7 @@ defmodule LightningWeb.WorkflowLive.HelpersTest do } result = Helpers.collaborative_editor_url(params, :new) - assert result == "/projects/proj-1/w/new?method=template" + assert result == "/projects/proj-1/w/new" end test "converts classical editor URL to collaborative for existing workflow" do