feat: open template via URL in linear mode (#6945)

## 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)
This commit is contained in:
Christian Byrne
2025-11-26 15:44:28 -08:00
committed by GitHub
parent 8b2c1fc45d
commit c5fe617347
3 changed files with 167 additions and 4 deletions

View File

@@ -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(

View File

@@ -80,7 +80,7 @@ const router = createRouter({
installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
keys: ['template', 'source']
keys: ['template', 'source', 'mode']
}
])

View File

@@ -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()
})
})