Skip to content

Commit 081b385

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
improvement(search): prioritize workspaces in command palette
1 parent 21c28d7 commit 081b385

3 files changed

Lines changed: 204 additions & 4 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import type { ComponentType } from 'react'
5+
import { act } from 'react'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
10+
11+
vi.mock('@sim/emcn', () => ({
12+
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
13+
Library: () => <svg aria-hidden='true' />,
14+
}))
15+
16+
vi.mock('next/navigation', () => ({
17+
useParams: () => ({ workspaceId: 'workspace-current' }),
18+
useRouter: () => ({ push: vi.fn() }),
19+
}))
20+
21+
vi.mock('posthog-js/react', () => ({
22+
usePostHog: () => null,
23+
}))
24+
25+
vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({
26+
useInvokeGlobalCommand: () => vi.fn(),
27+
}))
28+
29+
vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({
30+
SIDEBAR_SCROLL_EVENT: 'sidebar-scroll-to-item',
31+
}))
32+
33+
vi.mock('@/hooks/use-permission-config', () => ({
34+
usePermissionConfig: () => ({
35+
config: {
36+
hideIntegrationsTab: false,
37+
hideTablesTab: false,
38+
hideFilesTab: false,
39+
hideKnowledgeBaseTab: false,
40+
},
41+
}),
42+
}))
43+
44+
vi.mock('@/hooks/use-settings-navigation', () => ({
45+
useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }),
46+
}))
47+
48+
vi.mock('@/lib/posthog/client', () => ({
49+
captureEvent: vi.fn(),
50+
}))
51+
52+
vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({
53+
hasTriggerCapability: () => false,
54+
}))
55+
56+
import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal'
57+
import { useSearchModalStore } from '@/stores/modals/search/store'
58+
import { SEARCH_SECTIONS } from '@/stores/modals/search/types'
59+
60+
function TestIcon() {
61+
return <svg aria-hidden='true' />
62+
}
63+
64+
const workspace = {
65+
id: 'workspace-acme',
66+
name: 'Acme',
67+
href: '/workspace/workspace-acme/w',
68+
}
69+
70+
const chat = {
71+
id: 'chat-acme',
72+
name: 'Acme',
73+
href: '/workspace/workspace-current/chat/chat-acme',
74+
}
75+
76+
class ResizeObserverMock {
77+
observe = vi.fn()
78+
unobserve = vi.fn()
79+
disconnect = vi.fn()
80+
}
81+
82+
let container: HTMLDivElement
83+
let root: Root
84+
const originalScrollIntoView = Element.prototype.scrollIntoView
85+
86+
function headings(): string[] {
87+
return [...document.body.querySelectorAll('[cmdk-group-heading]')].map(
88+
(heading) => heading.textContent ?? ''
89+
)
90+
}
91+
92+
async function renderSearchModal({
93+
isOnWorkflowPage = false,
94+
}: {
95+
isOnWorkflowPage?: boolean
96+
} = {}) {
97+
await act(async () => {
98+
root.render(
99+
<SearchModal
100+
open
101+
onOpenChange={vi.fn()}
102+
workspaces={[workspace]}
103+
chats={[chat]}
104+
isOnWorkflowPage={isOnWorkflowPage}
105+
/>
106+
)
107+
})
108+
}
109+
110+
async function searchFor(value: string) {
111+
const input = document.body.querySelector<HTMLInputElement>('[cmdk-input]')
112+
if (!input) throw new Error('Search input not found')
113+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
114+
window.HTMLInputElement.prototype,
115+
'value'
116+
)?.set
117+
if (!nativeInputValueSetter) throw new Error('Native input value setter not found')
118+
119+
await act(async () => {
120+
nativeInputValueSetter.call(input, value)
121+
input.dispatchEvent(new Event('input', { bubbles: true }))
122+
})
123+
}
124+
125+
beforeEach(() => {
126+
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
127+
Element.prototype.scrollIntoView = vi.fn()
128+
container = document.createElement('div')
129+
document.body.appendChild(container)
130+
root = createRoot(container)
131+
useSearchModalStore.setState({
132+
isOpen: true,
133+
sections: null,
134+
pendingConnect: null,
135+
data: {
136+
blocks: [],
137+
tools: [],
138+
triggers: [],
139+
toolOperations: [],
140+
docs: [],
141+
isInitialized: true,
142+
},
143+
})
144+
})
145+
146+
afterEach(() => {
147+
act(() => root.unmount())
148+
container.remove()
149+
vi.clearAllMocks()
150+
vi.unstubAllGlobals()
151+
if (originalScrollIntoView) {
152+
Element.prototype.scrollIntoView = originalScrollIntoView
153+
} else {
154+
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
155+
}
156+
})
157+
158+
describe('SearchModal section ordering', () => {
159+
it('renders Workspaces directly after Actions and before Chats', async () => {
160+
await renderSearchModal()
161+
162+
expect(SEARCH_SECTIONS.slice(0, 2)).toEqual(['actions', 'workspaces'])
163+
expect(headings().slice(0, 3)).toEqual(['Actions', 'Workspaces', 'Chats'])
164+
})
165+
166+
it('keeps a matching workspace ahead of a matching chat', async () => {
167+
await renderSearchModal()
168+
await searchFor('Acme')
169+
170+
expect(headings()).toEqual(['Workspaces', 'Chats'])
171+
})
172+
173+
it('does not add Workspaces to a canvas-restricted palette', async () => {
174+
useSearchModalStore.setState({
175+
sections: ['blocks', 'tools', 'toolOperations'],
176+
data: {
177+
blocks: [
178+
{
179+
id: 'agent',
180+
name: 'Agent',
181+
icon: TestIcon as ComponentType<{ className?: string }>,
182+
bgColor: '#000000',
183+
type: 'agent',
184+
},
185+
],
186+
tools: [],
187+
triggers: [],
188+
toolOperations: [],
189+
docs: [],
190+
isInitialized: true,
191+
},
192+
})
193+
194+
await renderSearchModal({ isOnWorkflowPage: true })
195+
196+
expect(headings()).toEqual(['Blocks'])
197+
expect(document.body).not.toHaveTextContent('Workspaces')
198+
expect(document.body).not.toHaveTextContent('Acme')
199+
})
200+
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,9 @@ export function SearchModal({
714714
{showSection('actions') && (
715715
<ActionsGroup items={filteredActions} onSelect={handleActionSelect} />
716716
)}
717+
{showSection('workspaces') && (
718+
<WorkspacesGroup items={filteredWorkspaces} onSelect={handleWorkspaceSelect} />
719+
)}
717720
{showSection('connectedAccounts') && (
718721
<ConnectedAccountsGroup
719722
items={filteredConnectedAccounts}
@@ -753,9 +756,6 @@ export function SearchModal({
753756
{showSection('toolOperations') && (
754757
<ToolOpsGroup items={filteredToolOps} onSelect={handleToolOperationSelect} />
755758
)}
756-
{showSection('workspaces') && (
757-
<WorkspacesGroup items={filteredWorkspaces} onSelect={handleWorkspaceSelect} />
758-
)}
759759
{showSection('docs') && <DocsGroup items={filteredDocs} onSelect={handleDocSelect} />}
760760
{showSection('pages') && (
761761
<PagesGroup items={filteredPages} onSelect={handlePageSelect} />

apps/sim/stores/modals/search/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface SearchData {
5858
*/
5959
export const SEARCH_SECTIONS = [
6060
'actions',
61+
'workspaces',
6162
'connectedAccounts',
6263
'integrations',
6364
'blocks',
@@ -69,7 +70,6 @@ export const SEARCH_SECTIONS = [
6970
'files',
7071
'knowledgeBases',
7172
'toolOperations',
72-
'workspaces',
7373
'docs',
7474
'pages',
7575
] as const

0 commit comments

Comments
 (0)