Skip to content

Commit 0734cd0

Browse files
feat(slack): switch custom bots to the Sim Search app (#7811)
* fix(slack): show shared app installation beside custom bots * feat(slack): switch custom bots to the Sim Search app * fix(slack): show setup completion as a toast * feat(slack): link retired bots to the Sim Search app
1 parent ad05dae commit 0734cd0

22 files changed

Lines changed: 1176 additions & 122 deletions

apps/sim/app/api/knowledge/slack/setup/route.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ vi.mock('@/lib/knowledge/application/slack-search/setup', async () => {
1717
}
1818
})
1919

20-
import { createSlackSearchManifest } from '@/lib/slack-search/manifest'
20+
import { OrchestrationError } from '@/lib/core/orchestration/types'
2121
import { POST as start } from '@/app/api/knowledge/slack/oauth/route'
2222
import { POST as prepare } from '@/app/api/knowledge/slack/setup/route'
2323

@@ -35,13 +35,13 @@ describe.each([
3535
['prepare', prepare, mocks.prepare],
3636
['OAuth', start, mocks.start],
3737
] as const)('Slack %s route errors', (_name, route, execute) => {
38-
it('returns an actionable 400 for a non-HTTPS app URL', async () => {
39-
execute.mockImplementation(() =>
40-
createSlackSearchManifest(input.name, input.description, 'http://localhost:3000')
38+
it('returns application validation errors', async () => {
39+
execute.mockRejectedValue(
40+
new OrchestrationError('validation', 'Slack app credentials are required')
4141
)
4242
const response = await route(createMockRequest('POST', input))
4343
expect(response.status).toBe(400)
44-
expect(await response.json()).toMatchObject({ error: expect.stringContaining('public HTTPS') })
44+
expect(await response.json()).toMatchObject({ error: 'Slack app credentials are required' })
4545
expect(execute).toHaveBeenCalledOnce()
4646
})
4747

apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx

Lines changed: 198 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @vitest-environment jsdom */
22
import { act, type ReactNode } from 'react'
3+
import { ToastProvider } from '@sim/emcn'
34
import { createRoot, type Root } from 'react-dom/client'
45
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
56
import type { SlackSearchInstallationView } from '@/lib/api/contracts/knowledge/slack'
@@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({
1415
refetch: vi.fn(),
1516
copy: vi.fn(),
1617
removeError: null as Error | null,
18+
installError: null as Error | null,
1719
}))
1820
vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] }))
1921
vi.mock('@/components/settings/settings-panel', () => ({
@@ -32,7 +34,12 @@ vi.mock('@/hooks/queries/slack-search', () => ({
3234
error: mocks.removeError,
3335
reset: vi.fn(),
3436
}),
35-
useStartSlackSearchOAuth: () => ({ mutate: mocks.install, isPending: false, reset: vi.fn() }),
37+
useStartSlackSearchOAuth: () => ({
38+
mutate: mocks.install,
39+
isPending: false,
40+
error: mocks.installError,
41+
reset: vi.fn(),
42+
}),
3643
}))
3744

3845
import { OrganizationSearchSlack } from '@/app/o/[organizationId]/settings/components/organization-search-slack'
@@ -41,6 +48,7 @@ const installation: SlackSearchInstallationView = {
4148
id: 'installation-1',
4249
credentialId: 'credential-1',
4350
appId: 'A1',
51+
appKind: 'custom',
4452
teamId: 'T1',
4553
teamName: 'Test workspace',
4654
enabled: true,
@@ -57,13 +65,16 @@ beforeEach(() => {
5765
vi.stubGlobal('navigator', { clipboard: { writeText: mocks.copy } })
5866
mocks.copy.mockReset().mockResolvedValue(undefined)
5967
mocks.context.mockReturnValue({ organization: { id: 'org-1' }, viewer: { isAdmin: true } })
60-
mocks.list.mockReturnValue({ data: { installations: [], bots: [] } })
68+
mocks.list.mockReturnValue({
69+
data: { sharedAppAvailable: false, installations: [], bots: [] },
70+
})
6171
mocks.manifest.mockReturnValue({
6272
data: { manifest: '{}', existingApp: null, createAppUrl: 'https://api.slack.com/apps' },
6373
isPending: false,
6474
refetch: mocks.refetch,
6575
})
6676
mocks.removeError = null
77+
mocks.installError = null
6778
container = document.createElement('div')
6879
document.body.appendChild(container)
6980
root = createRoot(container)
@@ -77,15 +88,23 @@ async function render(installed = false) {
7788
if (installed) {
7889
mocks.list.mockReturnValue({
7990
data: {
91+
sharedAppAvailable: false,
8092
installations: [installation],
8193
bots: [{ id: 'credential-1', displayName: 'Sim Search' }],
8294
},
8395
})
8496
}
85-
await act(async () => root.render(<OrganizationSearchSlack />))
97+
await act(async () =>
98+
root.render(
99+
<ToastProvider>
100+
<OrganizationSearchSlack />
101+
</ToastProvider>
102+
)
103+
)
86104
}
87105
function button(label: string) {
88-
const element = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(
106+
const scope = document.querySelector('[role="dialog"]') ?? document
107+
const element = Array.from(scope.querySelectorAll<HTMLButtonElement>('button')).find(
89108
(element) => element.textContent?.trim() === label
90109
)
91110
expect(element, label).toBeDefined()
@@ -94,8 +113,8 @@ function button(label: string) {
94113
async function click(label: string) {
95114
await act(async () => button(label).click())
96115
}
97-
async function action(label: string) {
98-
const trigger = container.querySelector<HTMLButtonElement>('[aria-label="Sim Search actions"]')!
116+
async function action(label: string, name = 'Sim Search (custom bot)') {
117+
const trigger = container.querySelector<HTMLButtonElement>(`[aria-label="${name} actions"]`)!
99118
await act(async () => {
100119
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
101120
})
@@ -107,6 +126,174 @@ async function action(label: string) {
107126
}
108127

109128
describe('Slack Search settings and shared wizard', () => {
129+
it.each([
130+
{ state: 'no bots', installations: [] },
131+
{ state: 'custom bots', installations: [installation] },
132+
])(
133+
'installs the official app explicitly with $state and a custom source app',
134+
async ({ installations }) => {
135+
mocks.list.mockReturnValue({
136+
data: { sharedAppAvailable: true, installations, bots: [] },
137+
})
138+
mocks.manifest.mockReturnValue({
139+
data: {
140+
manifest: '{}',
141+
existingApp: { appId: 'A1', teamId: 'T1' },
142+
sharedAppId: 'A_SHARED',
143+
createAppUrl: 'https://api.slack.com/apps',
144+
},
145+
})
146+
await render()
147+
if (installations.length) {
148+
expect(container).toHaveTextContent('Reconnect required')
149+
expect(container).toHaveTextContent('Sim Search (custom bot)')
150+
await action('Install Sim Search')
151+
} else {
152+
await click('Install Sim Search')
153+
}
154+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent(
155+
'Install the Sim Search app'
156+
)
157+
expect(document.querySelectorAll('input')).toHaveLength(0)
158+
expect(mocks.install).not.toHaveBeenCalled()
159+
await click('Continue with Slack')
160+
expect(mocks.install).toHaveBeenCalledExactlyOnceWith(
161+
{
162+
organizationId: 'org-1',
163+
installationId: installations[0]?.id,
164+
name: 'Sim Search',
165+
description: expect.any(String),
166+
mode: 'shared',
167+
},
168+
expect.any(Object)
169+
)
170+
mocks.installError = new Error('Slack authorization failed. Try again.')
171+
await render()
172+
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
173+
'Slack authorization failed'
174+
)
175+
expect(mocks.configure).not.toHaveBeenCalled()
176+
expect(mocks.remove).not.toHaveBeenCalled()
177+
}
178+
)
179+
180+
it('reconnects an installed official app without offering a duplicate installation', async () => {
181+
mocks.list.mockReturnValue({
182+
data: {
183+
sharedAppAvailable: true,
184+
installations: [{ ...installation, appId: 'A_SHARED', appKind: 'shared' }],
185+
bots: [{ id: 'credential-1', displayName: 'Sim Search' }],
186+
},
187+
})
188+
mocks.manifest.mockReturnValue({ data: { sharedAppId: 'A_SHARED', existingApp: null } })
189+
await render()
190+
expect(container).not.toHaveTextContent('Install Sim Search')
191+
await action('Reconnect', 'Sim Search')
192+
await click('Continue with Slack')
193+
expect(mocks.install).toHaveBeenCalledWith(
194+
expect.objectContaining({ mode: 'shared', installationId: installation.id }),
195+
expect.any(Object)
196+
)
197+
})
198+
199+
it('does not switch to custom setup when shared installation becomes unavailable', async () => {
200+
mocks.list.mockReturnValue({
201+
data: { sharedAppAvailable: true, installations: [installation], bots: [] },
202+
})
203+
mocks.manifest.mockReturnValue({
204+
data: { sharedAppId: null, existingApp: null },
205+
refetch: mocks.refetch,
206+
})
207+
await render()
208+
await action('Install Sim Search')
209+
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
210+
'Sim Search installation is unavailable'
211+
)
212+
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Create Slack app')
213+
expect(button('Continue with Slack')).toBeDisabled()
214+
expect(mocks.install).not.toHaveBeenCalled()
215+
await click('Retry')
216+
expect(mocks.refetch).toHaveBeenCalledOnce()
217+
})
218+
219+
it('allows retrying shared setup after a preparation error with cached data', async () => {
220+
mocks.list.mockReturnValue({
221+
data: { sharedAppAvailable: true, installations: [installation], bots: [] },
222+
})
223+
mocks.manifest.mockReturnValue({
224+
data: { sharedAppId: 'A_SHARED', existingApp: null },
225+
error: new Error('Could not load Slack setup'),
226+
refetch: mocks.refetch,
227+
})
228+
await render()
229+
await action('Install Sim Search')
230+
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
231+
'Could not load Slack setup'
232+
)
233+
expect(button('Continue with Slack')).toBeDisabled()
234+
await click('Retry')
235+
expect(mocks.refetch).toHaveBeenCalledOnce()
236+
expect(mocks.install).not.toHaveBeenCalled()
237+
})
238+
239+
it('does not show official installation when it is unavailable', async () => {
240+
await render(true)
241+
expect(container).not.toHaveTextContent('Install Sim Search')
242+
expect(container).toHaveTextContent('Open in Slack')
243+
})
244+
245+
it('prompts the existing custom bot to reconnect when the feature becomes available', async () => {
246+
await render(true)
247+
expect(container).toHaveTextContent('Sim Search (custom bot)')
248+
expect(container).toHaveTextContent('Enabled')
249+
expect(container).not.toHaveTextContent('Reconnect required')
250+
mocks.list.mockReturnValue({
251+
data: { sharedAppAvailable: true, installations: [installation], bots: [] },
252+
})
253+
mocks.manifest.mockReturnValue({ data: { sharedAppId: 'A_SHARED', existingApp: null } })
254+
await render()
255+
expect(container).toHaveTextContent('Reconnect required')
256+
expect(container).not.toHaveTextContent('Install Sim Search')
257+
expect(mocks.install).not.toHaveBeenCalled()
258+
expect(mocks.configure).not.toHaveBeenCalled()
259+
await action('Install Sim Search')
260+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent(
261+
'Install the Sim Search app'
262+
)
263+
expect(button('Continue with Slack')).not.toBeDisabled()
264+
await click('Cancel')
265+
expect(mocks.install).not.toHaveBeenCalled()
266+
expect(mocks.remove).not.toHaveBeenCalled()
267+
})
268+
269+
it('shows the native app alongside the retained custom bot after installing', async () => {
270+
mocks.list.mockReturnValue({
271+
data: {
272+
sharedAppAvailable: true,
273+
installations: [
274+
{ ...installation, enabled: false },
275+
{
276+
...installation,
277+
id: 'native-installation',
278+
credentialId: 'native-credential',
279+
appId: 'A_SHARED',
280+
appKind: 'shared',
281+
},
282+
],
283+
bots: [],
284+
},
285+
})
286+
await render()
287+
expect(container.querySelector('[aria-label="Sim Search (custom bot) actions"]')).not.toBeNull()
288+
expect(container.querySelector('[aria-label="Sim Search actions"]')).not.toBeNull()
289+
expect(container).toHaveTextContent('Disabled')
290+
expect(container).toHaveTextContent('Enabled')
291+
expect(container).not.toHaveTextContent('Reconnect required')
292+
expect(container).not.toHaveTextContent('Install Sim Search')
293+
expect(container.querySelectorAll('a[href*="slack.com/app_redirect"]')).toHaveLength(2)
294+
expect(mocks.install).not.toHaveBeenCalled()
295+
})
296+
110297
it('starts with one setup action and a Slack app link, with no manifest preview or form', async () => {
111298
await render()
112299
expect(container.querySelectorAll('button')).toHaveLength(1)
@@ -124,13 +311,15 @@ describe('Slack Search settings and shared wizard', () => {
124311

125312
it('shows setup errors and blocks progression until the manifest loads', async () => {
126313
mocks.manifest.mockReturnValue({
127-
error: new Error('Slack needs a public HTTPS URL to send messages to Sim.'),
314+
error: new Error('Slack app configuration is unavailable.'),
128315
refetch: mocks.refetch,
129316
isPending: false,
130317
})
131318
await render()
132319
await click('Set up')
133-
expect(document.querySelector('[role="alert"]')).toHaveTextContent('public HTTPS')
320+
expect(document.querySelector('[role="alert"]')).toHaveTextContent(
321+
'Slack app configuration is unavailable.'
322+
)
134323
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1')
135324
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Continue')
136325
await click('Retry')
@@ -172,7 +361,7 @@ describe('Slack Search settings and shared wizard', () => {
172361
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Loading Slack setup')
173362
if (mode === 'shared') {
174363
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1')
175-
await click('Install Sim Search')
364+
await click('Continue with Slack')
176365
expect(mocks.install).toHaveBeenCalledWith(
177366
expect.objectContaining({ organizationId: 'org-1', mode: 'shared' }),
178367
expect.any(Object)

0 commit comments

Comments
 (0)