chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { createElement, type SVGProps } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
const { mockGetAllBlocks, mockGetToolOperationsIndex, mockGetTriggersForSidebar } = vi.hoisted(
|
||||
() => ({
|
||||
mockGetAllBlocks: vi.fn(),
|
||||
mockGetToolOperationsIndex: vi.fn(() => []),
|
||||
mockGetTriggersForSidebar: vi.fn(() => []),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/blocks', () => ({
|
||||
getAllBlocks: mockGetAllBlocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/search/tool-operations', () => ({
|
||||
getToolOperationsIndex: mockGetToolOperationsIndex,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({
|
||||
getTriggersForSidebar: mockGetTriggersForSidebar,
|
||||
}))
|
||||
|
||||
import {
|
||||
buildCommandSearchableOptionSearchValue,
|
||||
useSearchModalStore,
|
||||
} from '@/stores/modals/search/store'
|
||||
|
||||
function TestIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return createElement('svg', props)
|
||||
}
|
||||
|
||||
function createBlock(overrides: Partial<BlockConfig> = {}): BlockConfig {
|
||||
return {
|
||||
type: 'image_generator_v2',
|
||||
name: 'Image Generator',
|
||||
description: 'Generate images',
|
||||
category: 'tools',
|
||||
bgColor: '#4D5FFF',
|
||||
icon: TestIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'provider',
|
||||
title: 'Provider',
|
||||
type: 'dropdown',
|
||||
commandSearchable: true,
|
||||
options: [
|
||||
{ label: 'OpenAI', id: 'openai' },
|
||||
{ label: 'Fal.ai (Multi-Model)', id: 'falai' },
|
||||
{ label: 'Hidden Provider', id: 'hidden', hidden: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
tools: { access: ['image_generate'] },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('search modal store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSearchModalStore.setState({
|
||||
isOpen: false,
|
||||
data: {
|
||||
blocks: [],
|
||||
tools: [],
|
||||
triggers: [],
|
||||
toolOperations: [],
|
||||
docs: [],
|
||||
isInitialized: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCommandSearchableOptionSearchValue', () => {
|
||||
it('builds search terms for marked static dropdown options', () => {
|
||||
const block = createBlock()
|
||||
const searchValue = buildCommandSearchableOptionSearchValue(block)
|
||||
|
||||
expect(searchValue).toContain('Provider')
|
||||
expect(searchValue).toContain('Fal.ai (Multi-Model)')
|
||||
expect(searchValue).toContain('falai')
|
||||
expect(searchValue).not.toContain('Hidden Provider')
|
||||
expect(searchValue).not.toContain('hidden')
|
||||
})
|
||||
|
||||
it('does not index dropdowns that only use in-dropdown search', () => {
|
||||
const block = createBlock({
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'timezone',
|
||||
title: 'Timezone',
|
||||
type: 'dropdown',
|
||||
searchable: true,
|
||||
options: [{ label: 'UTC', id: 'utc' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(buildCommandSearchableOptionSearchValue(block)).toBe('')
|
||||
})
|
||||
|
||||
it('builds search terms for marked combobox option functions', () => {
|
||||
const block = createBlock({
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'model',
|
||||
title: 'Model',
|
||||
type: 'combobox',
|
||||
commandSearchable: true,
|
||||
options: () => [
|
||||
{ label: 'claude-sonnet-4-6', id: 'claude-sonnet-4-6' },
|
||||
{ label: 'Hidden Model', id: 'hidden-model', hidden: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const searchValue = buildCommandSearchableOptionSearchValue(block)
|
||||
|
||||
expect(searchValue).toContain('Model')
|
||||
expect(searchValue).toContain('claude-sonnet-4-6')
|
||||
expect(searchValue).not.toContain('Hidden Model')
|
||||
expect(searchValue).not.toContain('hidden-model')
|
||||
})
|
||||
})
|
||||
|
||||
it('adds command-searchable options to visible block search values without extra rows', () => {
|
||||
const visibleBlock = createBlock()
|
||||
const hiddenBlock = createBlock({
|
||||
type: 'hidden_generator',
|
||||
hideFromToolbar: true,
|
||||
})
|
||||
|
||||
mockGetAllBlocks.mockReturnValue([visibleBlock, hiddenBlock])
|
||||
|
||||
useSearchModalStore.getState().initializeData((blocks) => blocks)
|
||||
|
||||
const { tools } = useSearchModalStore.getState().data
|
||||
expect(tools).toHaveLength(1)
|
||||
expect(tools[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'image_generator_v2',
|
||||
searchValue: expect.stringContaining('Fal.ai (Multi-Model)'),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
|
||||
import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
import type {
|
||||
SearchBlockItem,
|
||||
SearchData,
|
||||
SearchDocItem,
|
||||
SearchModalState,
|
||||
SearchToolOperationItem,
|
||||
} from './types'
|
||||
|
||||
const initialData: SearchData = {
|
||||
blocks: [],
|
||||
tools: [],
|
||||
triggers: [],
|
||||
toolOperations: [],
|
||||
docs: [],
|
||||
isInitialized: false,
|
||||
}
|
||||
|
||||
type CommandSearchableOption = {
|
||||
label: string
|
||||
id: string
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
function getCommandSearchableOptions(subBlock: SubBlockConfig): CommandSearchableOption[] {
|
||||
if (!subBlock.options) return []
|
||||
|
||||
try {
|
||||
const options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options
|
||||
return Array.isArray(options) ? options : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCommandSearchableOptionSearchValue(block: BlockConfig): string {
|
||||
const terms = new Set<string>()
|
||||
|
||||
for (const subBlock of block.subBlocks) {
|
||||
if (
|
||||
(subBlock.type !== 'dropdown' && subBlock.type !== 'combobox') ||
|
||||
!subBlock.commandSearchable
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const option of getCommandSearchableOptions(subBlock)) {
|
||||
if (option.hidden) continue
|
||||
|
||||
const subBlockTitle = subBlock.title ?? subBlock.id
|
||||
terms.add(subBlockTitle)
|
||||
terms.add(option.label)
|
||||
terms.add(option.id)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(terms).join(' ')
|
||||
}
|
||||
|
||||
export const useSearchModalStore = create<SearchModalState>()(
|
||||
devtools(
|
||||
(set, _) => ({
|
||||
isOpen: false,
|
||||
sections: null,
|
||||
pendingConnect: null,
|
||||
data: initialData,
|
||||
|
||||
setOpen: (open: boolean) => {
|
||||
set({ isOpen: open, sections: null, pendingConnect: null })
|
||||
},
|
||||
|
||||
open: (options) => {
|
||||
set({
|
||||
isOpen: true,
|
||||
sections: options?.sections ?? null,
|
||||
pendingConnect: options?.pendingConnect ?? null,
|
||||
})
|
||||
},
|
||||
|
||||
close: () => {
|
||||
set({ isOpen: false, sections: null, pendingConnect: null })
|
||||
},
|
||||
|
||||
initializeData: (filterBlocks) => {
|
||||
const allBlocks = getAllBlocks()
|
||||
const filteredAllBlocks = filterBlocks(allBlocks) as typeof allBlocks
|
||||
|
||||
const regularBlocks: SearchBlockItem[] = []
|
||||
const tools: SearchBlockItem[] = []
|
||||
const docs: SearchDocItem[] = []
|
||||
|
||||
for (const block of filteredAllBlocks) {
|
||||
if (block.hideFromToolbar) continue
|
||||
|
||||
const searchItem: SearchBlockItem = {
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
searchValue: `${block.name} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`,
|
||||
}
|
||||
|
||||
if (block.category === 'blocks' && block.type !== 'starter') {
|
||||
regularBlocks.push(searchItem)
|
||||
} else if (block.category === 'tools') {
|
||||
tools.push(searchItem)
|
||||
}
|
||||
|
||||
if (block.docsLink) {
|
||||
docs.push({
|
||||
id: `docs-${block.type}`,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
href: block.docsLink,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const specialBlocks: SearchBlockItem[] = [
|
||||
{
|
||||
id: 'loop',
|
||||
name: 'Loop',
|
||||
icon: RepeatIcon,
|
||||
bgColor: '#2FB3FF',
|
||||
type: 'loop',
|
||||
},
|
||||
{
|
||||
id: 'parallel',
|
||||
name: 'Parallel',
|
||||
icon: SplitIcon,
|
||||
bgColor: '#FEE12B',
|
||||
type: 'parallel',
|
||||
},
|
||||
]
|
||||
|
||||
const blocks = [...regularBlocks, ...(filterBlocks(specialBlocks) as SearchBlockItem[])]
|
||||
|
||||
const allTriggers = getTriggersForSidebar()
|
||||
const filteredTriggers = filterBlocks(allTriggers) as typeof allTriggers
|
||||
const priorityOrder = ['Start', 'Schedule', 'Webhook']
|
||||
|
||||
const sortedTriggers = [...filteredTriggers].sort(
|
||||
(a: (typeof filteredTriggers)[number], b: (typeof filteredTriggers)[number]) => {
|
||||
const aIndex = priorityOrder.indexOf(a.name)
|
||||
const bIndex = priorityOrder.indexOf(b.name)
|
||||
const aHasPriority = aIndex !== -1
|
||||
const bHasPriority = bIndex !== -1
|
||||
|
||||
if (aHasPriority && bHasPriority) return aIndex - bIndex
|
||||
if (aHasPriority) return -1
|
||||
if (bHasPriority) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
)
|
||||
|
||||
const triggers = sortedTriggers.map(
|
||||
(block): SearchBlockItem => ({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
config: block,
|
||||
})
|
||||
)
|
||||
|
||||
const allowedBlockTypes = new Set(tools.map((t) => t.type))
|
||||
const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex()
|
||||
.filter((op) => allowedBlockTypes.has(op.blockType))
|
||||
.map((op) => {
|
||||
const aliasesStr = op.aliases?.length ? ` ${op.aliases.join(' ')}` : ''
|
||||
return {
|
||||
id: op.id,
|
||||
name: op.operationName,
|
||||
searchValue: `${op.serviceName} ${op.operationName}${aliasesStr}`,
|
||||
icon: op.icon,
|
||||
bgColor: op.bgColor,
|
||||
blockType: op.blockType,
|
||||
operationId: op.operationId,
|
||||
}
|
||||
})
|
||||
|
||||
set({
|
||||
data: {
|
||||
blocks,
|
||||
tools,
|
||||
triggers,
|
||||
toolOperations,
|
||||
docs,
|
||||
isInitialized: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
{ name: 'search-modal-store' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
/**
|
||||
* Represents a block item in the search results.
|
||||
*/
|
||||
export interface SearchBlockItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
bgColor: string
|
||||
type: string
|
||||
config?: BlockConfig
|
||||
searchValue?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a tool operation item in the search results.
|
||||
*/
|
||||
export interface SearchToolOperationItem {
|
||||
id: string
|
||||
name: string
|
||||
searchValue: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
bgColor: string
|
||||
blockType: string
|
||||
operationId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a doc item in the search results.
|
||||
*/
|
||||
export interface SearchDocItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
href: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-computed search data that is initialized on app load.
|
||||
*/
|
||||
export interface SearchData {
|
||||
blocks: SearchBlockItem[]
|
||||
tools: SearchBlockItem[]
|
||||
triggers: SearchBlockItem[]
|
||||
toolOperations: SearchToolOperationItem[]
|
||||
docs: SearchDocItem[]
|
||||
isInitialized: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Every result group the search modal can render, in render order. Used to
|
||||
* restrict the palette to a subset of sections when opened for a specific
|
||||
* intent (e.g. a drag-release that should only offer canvas-insertable items).
|
||||
*/
|
||||
export const SEARCH_SECTIONS = [
|
||||
'actions',
|
||||
'connectedAccounts',
|
||||
'integrations',
|
||||
'blocks',
|
||||
'tools',
|
||||
'triggers',
|
||||
'chats',
|
||||
'workflows',
|
||||
'tables',
|
||||
'files',
|
||||
'knowledgeBases',
|
||||
'toolOperations',
|
||||
'workspaces',
|
||||
'docs',
|
||||
'pages',
|
||||
] as const
|
||||
|
||||
/** A single search-modal result group. */
|
||||
export type SearchSection = (typeof SEARCH_SECTIONS)[number]
|
||||
|
||||
/**
|
||||
* Context handed to the palette when it is opened to complete an edge
|
||||
* drag-release: the dragged source handle and the release point. A selection
|
||||
* stamps it onto its event so the canvas places the block at the drop point and
|
||||
* wires it from that handle.
|
||||
*/
|
||||
export interface PendingConnect {
|
||||
source: { nodeId: string; handleId: string }
|
||||
screenX: number
|
||||
screenY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Global state for the universal search modal.
|
||||
*
|
||||
* Centralizing this state in a store allows any component (e.g. sidebar,
|
||||
* workflow command list, keyboard shortcuts) to open or close the modal
|
||||
* without relying on DOM events or prop drilling.
|
||||
*/
|
||||
export interface SearchModalState {
|
||||
/** Whether the search modal is currently open. */
|
||||
isOpen: boolean
|
||||
|
||||
/**
|
||||
* When set, the palette renders only these sections; `null` shows all of them.
|
||||
*/
|
||||
sections: SearchSection[] | null
|
||||
|
||||
/**
|
||||
* Pending edge drag-release the palette was opened to complete. A selection
|
||||
* stamps it onto its event; other add-block dispatchers carry none, so only a
|
||||
* genuine palette pick completes the connection. `null` for ordinary opens.
|
||||
*/
|
||||
pendingConnect: PendingConnect | null
|
||||
|
||||
/** Pre-computed search data. */
|
||||
data: SearchData
|
||||
|
||||
/**
|
||||
* Explicitly set the open state of the modal. Always resets to the full
|
||||
* palette (no section restriction, no pending connect).
|
||||
*/
|
||||
setOpen: (open: boolean) => void
|
||||
|
||||
/**
|
||||
* Convenience method to open the modal. Pass `sections` to restrict the
|
||||
* palette to a subset of result groups, and `pendingConnect` to complete an
|
||||
* edge drag-release with the selection.
|
||||
*/
|
||||
open: (options?: { sections?: SearchSection[]; pendingConnect?: PendingConnect }) => void
|
||||
|
||||
/**
|
||||
* Convenience method to close the modal.
|
||||
*/
|
||||
close: () => void
|
||||
|
||||
/**
|
||||
* Initialize search data. Called once on app load.
|
||||
*/
|
||||
initializeData: (filterBlocks: <T extends { type: string }>(blocks: T[]) => T[]) => void
|
||||
}
|
||||
Reference in New Issue
Block a user