From aa3209f6e5df4043b31ddb783d25ff4a9dec427a Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 13:16:29 +0100 Subject: [PATCH 1/8] feat(templates): wire up Browse Templates modal on /new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking "Browse templates" on the /new landing screen opens a modal listing base templates (webhook + cron) plus any user-created templates. Clicking a template creates the workflow immediately and dismisses the landing screen — no separate confirm step. - Add showTemplateBrowserModal state + open/close actions to UIStore - Add WorkflowTemplateBrowserModal component (lazy fetch on open, one-click create, Escape to close, responsive grid + panel width) - Connect LandingScreen.onBrowseTemplates to the new modal --- .../CollaborativeEditor.tsx | 12 +- .../WorkflowTemplateBrowserModal.tsx | 184 ++++++++++++++++++ assets/js/collaborative-editor/hooks/useUI.ts | 16 ++ .../stores/createUIStore.ts | 17 ++ assets/js/collaborative-editor/types/ui.ts | 9 + 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx index 6c4a9d95ca..e29978079c 100644 --- a/assets/js/collaborative-editor/CollaborativeEditor.tsx +++ b/assets/js/collaborative-editor/CollaborativeEditor.tsx @@ -16,6 +16,7 @@ import { Toaster } from './components/ui/Toaster'; import { VersionDebugLogger } from './components/VersionDebugLogger'; import { VersionDropdown } from './components/VersionDropdown'; import { WorkflowEditor } from './components/WorkflowEditor'; +import { WorkflowTemplateBrowserModal } from './components/WorkflowTemplateBrowserModal'; import { YAMLImportModal } from './components/YAMLImportModal'; import { CredentialModalProvider } from './contexts/CredentialModalContext'; import { LiveViewActionsProvider } from './contexts/LiveViewActionsContext'; @@ -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/WorkflowTemplateBrowserModal.tsx b/assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx new file mode 100644 index 0000000000..eb985556b8 --- /dev/null +++ b/assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx @@ -0,0 +1,184 @@ +import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; +import { useEffect, useState } from 'react'; + +import { cn } from '#/utils/cn'; + +import { parseWorkflowYAML, convertWorkflowSpecToState } from '../../yaml/util'; +import { fetchTemplates } from '../api/templates'; +import { BASE_TEMPLATES } from '../constants/baseTemplates'; +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'; + +export function WorkflowTemplateBrowserModal() { + const isOpen = useShowTemplateBrowserModal(); + const { closeTemplateBrowserModal, dismissLandingScreen } = useUICommands(); + const { provider } = useSession(); + const channel = provider?.channel; + const { importWorkflow, saveWorkflow } = useWorkflowActions(); + + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + let cols = 1; + if (templates.length > 6) cols = 3; + else if (templates.length > 3) cols = 2; + + useKeyboardShortcut('Escape', closeTemplateBrowserModal, 100, { + enabled: isOpen, + }); + + // Lazy fetch — only when modal opens, not on every /new load + useEffect(() => { + if (!isOpen || !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 handleSelect = async (template: Template) => { + if (isSaving) return; + setIsSaving(true); + 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.', + }); + setIsSaving(false); + return; + } + closeTemplateBrowserModal(); + dismissLandingScreen(); + } catch { + notifications.alert({ + title: 'Failed to create workflow', + description: 'Please check your connection and try again.', + }); + setIsSaving(false); + } + }; + + return ( + + +
+ + {/* Header */} +
+

Templates

+ +
+ + {/* Content */} +
+ {loading ? ( +

+ Loading templates... +

+ ) : ( +
+ {templates.map(template => ( + void handleSelect(template)} + /> + ))} +
+ )} +
+
+
+
+ ); +} + +interface TemplateSelectCardProps { + template: Template; + disabled: boolean; + onClick: () => void; +} + +function TemplateSelectCard({ + template, + disabled, + onClick, +}: TemplateSelectCardProps) { + return ( + + ); +} diff --git a/assets/js/collaborative-editor/hooks/useUI.ts b/assets/js/collaborative-editor/hooks/useUI.ts index 4974e7b403..04075bc7a2 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/stores/createUIStore.ts b/assets/js/collaborative-editor/stores/createUIStore.ts index 8db26c4aea..51a7a2274f 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 e321577053..54e3ea17f3 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; From 6622aaa80d2c06d0a0de0d30f8e46ca13a5f8bcf Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 17:26:36 +0100 Subject: [PATCH 2/8] fix(useWorkflow): use liveSocket.historyPatch for post-save URL update on new workflows --- assets/js/collaborative-editor/hooks/useWorkflow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/collaborative-editor/hooks/useWorkflow.tsx b/assets/js/collaborative-editor/hooks/useWorkflow.tsx index 30952149de..3d3abd4244 100644 --- a/assets/js/collaborative-editor/hooks/useWorkflow.tsx +++ b/assets/js/collaborative-editor/hooks/useWorkflow.tsx @@ -425,7 +425,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); + window.liveSocket?.historyPatch(newUrl, 'replace'); // Clear template state in UI store uiStore.selectTemplate(null); From 5a7677b47bd9ee9f4df7e77d9ff694cf7c30803b Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 18:04:15 +0100 Subject: [PATCH 3/8] fix(WorkflowTemplateBrowserModal): improve template card focus ring and description display --- .../components/WorkflowTemplateBrowserModal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx b/assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx index eb985556b8..cbd4a790a0 100644 --- a/assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx +++ b/assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx @@ -122,7 +122,7 @@ export function WorkflowTemplateBrowserModal() { {/* Content */} -
+
{loading ? (

Loading templates... @@ -171,11 +171,11 @@ function TemplateSelectCard({ className="w-full h-full text-left rounded-lg border border-gray-200 bg-white p-3 hover:border-gray-300 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed - focus:outline-none focus-visible:ring focus-visible:ring-gray-300" + focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-400" >

{template.name}

{template.description && ( -

+

{template.description}

)} From 1796df7513bae6686ab18d85e417139d6949f489 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Tue, 30 Jun 2026 19:22:52 +0100 Subject: [PATCH 4/8] refactor(templates): split WorkflowTemplateBrowserModal into portable UI and Lightning wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original component mixed UI rendering with Phoenix channel fetching, Y.Doc workflow imports, and global store hooks — making it impossible to render or iterate on in isolation (Storybook, tests, or a plain React context) without standing up the full collaborative editor infrastructure. Split into two components: - TemplateBrowserModal: pure presentational modal, takes only plain props, no Lightning dependencies. Product can open this file and iterate freely. - TemplateBrowserModalWrapper: thin wiring layer that connects the modal to the Phoenix channel, Y.Doc workflow store, and global UI state. Also fixes isSaving not being reset on successful template selection, and clears the search query each time the modal opens. --- .../CollaborativeEditor.tsx | 4 +- .../components/TemplateBrowserModal.tsx | 209 ++++++++++++++++++ .../TemplateBrowserModalWrapper.tsx | 95 ++++++++ .../WorkflowTemplateBrowserModal.tsx | 184 --------------- 4 files changed, 306 insertions(+), 186 deletions(-) create mode 100644 assets/js/collaborative-editor/components/TemplateBrowserModal.tsx create mode 100644 assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx delete mode 100644 assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx index e29978079c..6b4863071b 100644 --- a/assets/js/collaborative-editor/CollaborativeEditor.tsx +++ b/assets/js/collaborative-editor/CollaborativeEditor.tsx @@ -12,11 +12,11 @@ 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'; import { WorkflowEditor } from './components/WorkflowEditor'; -import { WorkflowTemplateBrowserModal } from './components/WorkflowTemplateBrowserModal'; import { YAMLImportModal } from './components/YAMLImportModal'; import { CredentialModalProvider } from './contexts/CredentialModalContext'; import { LiveViewActionsProvider } from './contexts/LiveViewActionsContext'; @@ -206,7 +206,7 @@ function LandingScreenWrapper({ 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 0000000000..f01ab10938 --- /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[], + query: string +): WorkflowTemplate[] { + const q = query.trim().toLowerCase(); + 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 => 'isBase' in t + ); + const userTemplates = templates.filter( + (t): t is WorkflowTemplate => !('isBase' in t) + ); + const filteredUserTemplates = filterTemplates(userTemplates, searchQuery); + const q = searchQuery.trim().toLowerCase(); + 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:ring-gray-400 + 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 0000000000..16a8861968 --- /dev/null +++ b/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react'; + +import { parseWorkflowYAML, convertWorkflowSpecToState } from '../../yaml/util'; +import { fetchTemplates } from '../api/templates'; +import { BASE_TEMPLATES } from '../constants/baseTemplates'; +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(); + const channel = provider?.channel; + const { importWorkflow, saveWorkflow } = useWorkflowActions(); + + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(false); + const [isSaving, setIsSaving] = 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 || !channel) return; + + setSearchQuery(''); + + 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 handleSelect = async (template: Template) => { + if (isSaving) return; + setIsSaving(true); + 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.', + }); + setIsSaving(false); + return; + } + setIsSaving(false); + closeTemplateBrowserModal(); + dismissLandingScreen(); + } catch { + notifications.alert({ + title: 'Failed to create workflow', + description: 'Please check your connection and try again.', + }); + setIsSaving(false); + } + }; + + return ( + void handleSelect(template)} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + /> + ); +} diff --git a/assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx b/assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx deleted file mode 100644 index cbd4a790a0..0000000000 --- a/assets/js/collaborative-editor/components/WorkflowTemplateBrowserModal.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; -import { useEffect, useState } from 'react'; - -import { cn } from '#/utils/cn'; - -import { parseWorkflowYAML, convertWorkflowSpecToState } from '../../yaml/util'; -import { fetchTemplates } from '../api/templates'; -import { BASE_TEMPLATES } from '../constants/baseTemplates'; -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'; - -export function WorkflowTemplateBrowserModal() { - const isOpen = useShowTemplateBrowserModal(); - const { closeTemplateBrowserModal, dismissLandingScreen } = useUICommands(); - const { provider } = useSession(); - const channel = provider?.channel; - const { importWorkflow, saveWorkflow } = useWorkflowActions(); - - const [templates, setTemplates] = useState([]); - const [loading, setLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); - - let cols = 1; - if (templates.length > 6) cols = 3; - else if (templates.length > 3) cols = 2; - - useKeyboardShortcut('Escape', closeTemplateBrowserModal, 100, { - enabled: isOpen, - }); - - // Lazy fetch — only when modal opens, not on every /new load - useEffect(() => { - if (!isOpen || !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 handleSelect = async (template: Template) => { - if (isSaving) return; - setIsSaving(true); - 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.', - }); - setIsSaving(false); - return; - } - closeTemplateBrowserModal(); - dismissLandingScreen(); - } catch { - notifications.alert({ - title: 'Failed to create workflow', - description: 'Please check your connection and try again.', - }); - setIsSaving(false); - } - }; - - return ( - - -
- - {/* Header */} -
-

Templates

- -
- - {/* Content */} -
- {loading ? ( -

- Loading templates... -

- ) : ( -
- {templates.map(template => ( - void handleSelect(template)} - /> - ))} -
- )} -
-
-
-
- ); -} - -interface TemplateSelectCardProps { - template: Template; - disabled: boolean; - onClick: () => void; -} - -function TemplateSelectCard({ - template, - disabled, - onClick, -}: TemplateSelectCardProps) { - return ( - - ); -} From c069aa97c9f4a7407f29e2658a359a989050fa11 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Wed, 1 Jul 2026 09:19:47 +0100 Subject: [PATCH 5/8] test(filterTemplates): add unit tests for template search filter --- .../components/TemplateDetailsCard.test.tsx | 3 +- .../utils/filterTemplates.test.ts | 77 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 assets/test/collaborative-editor/utils/filterTemplates.test.ts diff --git a/assets/test/collaborative-editor/components/TemplateDetailsCard.test.tsx b/assets/test/collaborative-editor/components/TemplateDetailsCard.test.tsx index 6c9ddefa77..0bcfae7142 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/utils/filterTemplates.test.ts b/assets/test/collaborative-editor/utils/filterTemplates.test.ts new file mode 100644 index 0000000000..5643c5ba7a --- /dev/null +++ b/assets/test/collaborative-editor/utils/filterTemplates.test.ts @@ -0,0 +1,77 @@ +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('returns all templates when query is only whitespace', () => { + 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('is case-insensitive', () => { + const template = make({ name: 'OpenMRS Integration' }); + expect(filterTemplates([template], 'OPENMRS')).toEqual([template]); + expect(filterTemplates([template], 'openmrs')).toEqual([template]); + expect(filterTemplates([template], 'OpenMRS')).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]); + }); +}); From 701b892907a97f66885a1de95d3babf669e9dc52 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Wed, 1 Jul 2026 09:44:26 +0100 Subject: [PATCH 6/8] fix(TemplateBrowserModal): base templates, aria-label, isBase type guard --- .../components/TemplateBrowserModal.tsx | 9 +++++---- .../components/TemplateBrowserModalWrapper.tsx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx b/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx index f01ab10938..486766e8b0 100644 --- a/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx +++ b/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx @@ -50,10 +50,10 @@ export function TemplateBrowserModal({ onSearchChange, }: TemplateBrowserModalProps) { const baseTemplates = templates.filter( - (t): t is BaseTemplate => 'isBase' in t + (t): t is BaseTemplate => (t as BaseTemplate).isBase === true ); const userTemplates = templates.filter( - (t): t is WorkflowTemplate => !('isBase' in t) + (t): t is WorkflowTemplate => (t as BaseTemplate).isBase !== true ); const filteredUserTemplates = filterTemplates(userTemplates, searchQuery); const q = searchQuery.trim().toLowerCase(); @@ -112,13 +112,14 @@ export function TemplateBrowserModal({ 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:ring-gray-400 + focus:outline-none focus-visible:ring-1 focus-visible:border-gray-300 focus-visible:ring-gray-300 disabled:opacity-50" />
@@ -196,7 +197,7 @@ function TemplateSelectCard({ className="w-full h-full text-left rounded-lg border border-gray-200 bg-white p-3 hover:border-gray-300 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed - focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-400" + focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 focus-visible:border-gray-300" >

{template.name}

{template.description && ( diff --git a/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx b/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx index 16a8861968..bf7fd7ae09 100644 --- a/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx +++ b/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx @@ -19,7 +19,7 @@ export function TemplateBrowserModalWrapper() { const channel = provider?.channel; const { importWorkflow, saveWorkflow } = useWorkflowActions(); - const [templates, setTemplates] = useState([]); + const [templates, setTemplates] = useState(BASE_TEMPLATES); const [loading, setLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [searchQuery, setSearchQuery] = useState(''); From b40d6c2e1141787d36d0aadc020d19616c6eed48 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Wed, 1 Jul 2026 13:59:04 +0100 Subject: [PATCH 7/8] fix(useWorkflow): suppress internal error toast when silent option is set When saveWorkflow({ silent: true }) was called from the template browser or YAML import flows, handleSaveError would fire a toast before re-throwing, causing a second toast alongside the caller's own error message. Extends the silent flag to the error path so callers that pass silent: true own their own error messaging. --- assets/js/collaborative-editor/hooks/useWorkflow.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/js/collaborative-editor/hooks/useWorkflow.tsx b/assets/js/collaborative-editor/hooks/useWorkflow.tsx index 3d3abd4244..adc323bbab 100644 --- a/assets/js/collaborative-editor/hooks/useWorkflow.tsx +++ b/assets/js/collaborative-editor/hooks/useWorkflow.tsx @@ -451,8 +451,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 +521,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; } From 1ac4fd12763ed1aabcac9c70d722191f8ab6225f Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Wed, 1 Jul 2026 14:00:32 +0100 Subject: [PATCH 8/8] fix(TemplateBrowserModal): reset search query on open, narrow session subscription, move query normalisation to call site --- .../components/TemplateBrowserModal.tsx | 5 ++--- .../components/TemplateBrowserModalWrapper.tsx | 6 +++--- .../collaborative-editor/utils/filterTemplates.test.ts | 10 ++-------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx b/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx index 486766e8b0..48db256922 100644 --- a/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx +++ b/assets/js/collaborative-editor/components/TemplateBrowserModal.tsx @@ -21,9 +21,8 @@ function matchesQuery( export function filterTemplates( templates: WorkflowTemplate[], - query: string + q: string ): WorkflowTemplate[] { - const q = query.trim().toLowerCase(); if (!q) return templates; return templates.filter(t => matchesQuery(t, q)); } @@ -55,8 +54,8 @@ export function TemplateBrowserModal({ const userTemplates = templates.filter( (t): t is WorkflowTemplate => (t as BaseTemplate).isBase !== true ); - const filteredUserTemplates = filterTemplates(userTemplates, searchQuery); const q = searchQuery.trim().toLowerCase(); + const filteredUserTemplates = filterTemplates(userTemplates, q); const anyBaseTemplateMatches = q.length > 0 && baseTemplates.some(t => matchesQuery(t, q)); diff --git a/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx b/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx index bf7fd7ae09..2041350b43 100644 --- a/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx +++ b/assets/js/collaborative-editor/components/TemplateBrowserModalWrapper.tsx @@ -15,7 +15,7 @@ import { TemplateBrowserModal } from './TemplateBrowserModal'; export function TemplateBrowserModalWrapper() { const isOpen = useShowTemplateBrowserModal(); const { closeTemplateBrowserModal, dismissLandingScreen } = useUICommands(); - const { provider } = useSession(); + const provider = useSession(s => s.provider); const channel = provider?.channel; const { importWorkflow, saveWorkflow } = useWorkflowActions(); @@ -30,9 +30,9 @@ export function TemplateBrowserModalWrapper() { // Lazy fetch — only when modal opens, not on every /new load useEffect(() => { - if (!isOpen || !channel) return; - + if (!isOpen) return; setSearchQuery(''); + if (!channel) return; const load = async () => { setLoading(true); diff --git a/assets/test/collaborative-editor/utils/filterTemplates.test.ts b/assets/test/collaborative-editor/utils/filterTemplates.test.ts index 5643c5ba7a..152c353a7d 100644 --- a/assets/test/collaborative-editor/utils/filterTemplates.test.ts +++ b/assets/test/collaborative-editor/utils/filterTemplates.test.ts @@ -20,11 +20,6 @@ describe('filterTemplates', () => { expect(filterTemplates(templates, '')).toEqual(templates); }); - it('returns all templates when query is only whitespace', () => { - 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' }); @@ -46,11 +41,10 @@ describe('filterTemplates', () => { expect(filterTemplates([match, noMatch], 'kobo')).toEqual([match]); }); - it('is case-insensitive', () => { + it('matches case-insensitively (caller normalizes to lowercase)', () => { const template = make({ name: 'OpenMRS Integration' }); - expect(filterTemplates([template], 'OPENMRS')).toEqual([template]); expect(filterTemplates([template], 'openmrs')).toEqual([template]); - expect(filterTemplates([template], 'OpenMRS')).toEqual([template]); + expect(filterTemplates([template], 'openm')).toEqual([template]); }); it('returns empty array when nothing matches', () => {