From c5fe61734746cf75a2fe6aad75fc8c92cc3b2591 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Wed, 26 Nov 2025 15:44:28 -0800 Subject: [PATCH] feat: open template via URL in linear mode (#6945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds `mode` as a valid url query param when opening template from URL. https://github.com/user-attachments/assets/8e7efb1c-d842-4953-822a-f3cea7ddecb6 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6945-feat-open-template-via-URL-in-linear-mode-2b76d73d365081d8ad4af3d49a68a4ff) by [Unito](https://www.unito.io) --- .../composables/useTemplateUrlLoader.ts | 38 ++++- src/router.ts | 2 +- .../composables/useTemplateUrlLoader.test.ts | 131 +++++++++++++++++- 3 files changed, 167 insertions(+), 4 deletions(-) diff --git a/src/platform/workflow/templates/composables/useTemplateUrlLoader.ts b/src/platform/workflow/templates/composables/useTemplateUrlLoader.ts index ea5f97302..0305acaf7 100644 --- a/src/platform/workflow/templates/composables/useTemplateUrlLoader.ts +++ b/src/platform/workflow/templates/composables/useTemplateUrlLoader.ts @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router' import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager' import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useTemplateWorkflows } from './useTemplateWorkflows' @@ -13,9 +14,10 @@ import { useTemplateWorkflows } from './useTemplateWorkflows' * Supports URLs like: * - /?template=flux_simple (loads with default source) * - /?template=flux_simple&source=custom (loads from custom source) + * - /?template=flux_simple&mode=linear (loads template in linear mode) * * Input validation: - * - Template and source parameters must match: ^[a-zA-Z0-9_-]+$ + * - Template, source, and mode parameters must match: ^[a-zA-Z0-9_-]+$ * - Invalid formats are rejected with console warnings */ export function useTemplateUrlLoader() { @@ -24,7 +26,10 @@ export function useTemplateUrlLoader() { const { t } = useI18n() const toast = useToast() const templateWorkflows = useTemplateWorkflows() + const canvasStore = useCanvasStore() const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE + const SUPPORTED_MODES = ['linear'] as const + type SupportedMode = (typeof SUPPORTED_MODES)[number] /** * Validates parameter format to prevent path traversal and injection attacks @@ -34,12 +39,20 @@ export function useTemplateUrlLoader() { } /** - * Removes template and source parameters from URL + * Type guard to check if a value is a supported mode + */ + const isSupportedMode = (mode: string): mode is SupportedMode => { + return SUPPORTED_MODES.includes(mode as SupportedMode) + } + + /** + * Removes template, source, and mode parameters from URL */ const cleanupUrlParams = () => { const newQuery = { ...route.query } delete newQuery.template delete newQuery.source + delete newQuery.mode void router.replace({ query: newQuery }) } @@ -70,6 +83,24 @@ export function useTemplateUrlLoader() { return } + const modeParam = route.query.mode as string | undefined + + if ( + modeParam && + (typeof modeParam !== 'string' || !isValidParameter(modeParam)) + ) { + console.warn( + `[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}` + ) + return + } + + if (modeParam && !isSupportedMode(modeParam)) { + console.warn( + `[useTemplateUrlLoader] Unsupported mode parameter: ${modeParam}. Supported modes: ${SUPPORTED_MODES.join(', ')}` + ) + } + try { await templateWorkflows.loadTemplates() @@ -87,6 +118,9 @@ export function useTemplateUrlLoader() { }), life: 3000 }) + } else if (modeParam === 'linear') { + // Set linear mode after successful template load + canvasStore.linearMode = true } } catch (error) { console.error( diff --git a/src/router.ts b/src/router.ts index bb9addcfd..d1291e079 100644 --- a/src/router.ts +++ b/src/router.ts @@ -80,7 +80,7 @@ const router = createRouter({ installPreservedQueryTracker(router, [ { namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE, - keys: ['template', 'source'] + keys: ['template', 'source', 'mode'] } ]) diff --git a/tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts b/tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts index 5036cd730..650fa817c 100644 --- a/tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts +++ b/tests-ui/tests/platform/workflow/templates/composables/useTemplateUrlLoader.test.ts @@ -8,8 +8,9 @@ import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/ * Tests the behavior of loading templates via URL query parameters: * - ?template=flux_simple loads the template * - ?template=flux_simple&source=custom loads from custom source + * - ?template=flux_simple&mode=linear loads template in linear mode * - Invalid template shows error toast - * - Input validation for template and source parameters + * - Input validation for template, source, and mode parameters */ const preservedQueryMocks = vi.hoisted(() => ({ @@ -70,10 +71,20 @@ vi.mock('vue-i18n', () => ({ }) })) +// Mock canvas store +const mockCanvasStore = { + linearMode: false +} + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => mockCanvasStore +})) + describe('useTemplateUrlLoader', () => { beforeEach(() => { vi.clearAllMocks() mockQueryParams = {} + mockCanvasStore.linearMode = false }) it('does not load template when no query param present', () => { @@ -236,6 +247,7 @@ describe('useTemplateUrlLoader', () => { mockQueryParams = { template: 'flux_simple', source: 'custom', + mode: 'linear', other: 'param' } @@ -270,4 +282,121 @@ describe('useTemplateUrlLoader', () => { query: { other: 'param' } }) }) + + it('sets linear mode when mode=linear and template loads successfully', async () => { + mockQueryParams = { template: 'flux_simple', mode: 'linear' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith( + 'flux_simple', + 'default' + ) + expect(mockCanvasStore.linearMode).toBe(true) + }) + + it('does not set linear mode when template loading fails', async () => { + mockQueryParams = { template: 'invalid-template', mode: 'linear' } + mockLoadWorkflowTemplate.mockResolvedValueOnce(false) + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockCanvasStore.linearMode).toBe(false) + }) + + it('does not set linear mode when mode parameter is not linear', async () => { + mockQueryParams = { template: 'flux_simple', mode: 'graph' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith( + 'flux_simple', + 'default' + ) + expect(mockCanvasStore.linearMode).toBe(false) + }) + + it('rejects invalid mode parameter with special characters', () => { + mockQueryParams = { template: 'flux_simple', mode: '../malicious' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + void loadTemplateFromUrl() + + expect(mockLoadTemplates).not.toHaveBeenCalled() + }) + + it('handles array mode params correctly', () => { + // Vue Router can return string[] for duplicate params + mockQueryParams = { + template: 'flux_simple', + mode: ['linear', 'graph'] as any + } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + void loadTemplateFromUrl() + + // Should not load when mode param is an array + expect(mockLoadTemplates).not.toHaveBeenCalled() + }) + + it('warns about unsupported mode values but continues loading', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockQueryParams = { template: 'flux_simple', mode: 'unsupported' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(consoleSpy).toHaveBeenCalledWith( + '[useTemplateUrlLoader] Unsupported mode parameter: unsupported. Supported modes: linear' + ) + expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith( + 'flux_simple', + 'default' + ) + expect(mockCanvasStore.linearMode).toBe(false) + + consoleSpy.mockRestore() + }) + + it('accepts supported mode parameter: linear', async () => { + mockQueryParams = { template: 'flux_simple', mode: 'linear' } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith( + 'flux_simple', + 'default' + ) + expect(mockCanvasStore.linearMode).toBe(true) + }) + + it('accepts valid format but warns about unsupported modes', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const unsupportedModes = ['graph', 'mode123', 'my_mode-2'] + + for (const mode of unsupportedModes) { + vi.clearAllMocks() + consoleSpy.mockClear() + mockCanvasStore.linearMode = false + mockQueryParams = { template: 'flux_simple', mode } + + const { loadTemplateFromUrl } = useTemplateUrlLoader() + await loadTemplateFromUrl() + + expect(consoleSpy).toHaveBeenCalledWith( + `[useTemplateUrlLoader] Unsupported mode parameter: ${mode}. Supported modes: linear` + ) + expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith( + 'flux_simple', + 'default' + ) + expect(mockCanvasStore.linearMode).toBe(false) + } + + consoleSpy.mockRestore() + }) })