Skip to content

Commit bc63892

Browse files
fix(slack): make manifest copying direct and order setup steps
1 parent 0ede40b commit bc63892

8 files changed

Lines changed: 444 additions & 191 deletions

File tree

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

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -296,15 +296,20 @@ describe('Slack Search settings and shared wizard', () => {
296296
expect(mocks.install).not.toHaveBeenCalled()
297297
})
298298

299-
it('starts with one setup action and a Slack app link, with no manifest preview or form', async () => {
299+
it('offers direct manifest copying and a Slack app link before collecting credentials', async () => {
300300
await render()
301301
expect(container.querySelectorAll('button')).toHaveLength(1)
302302
await click('Set up')
303303
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Create Slack app')
304304
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Step 1')
305-
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('App manifest')
305+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('App manifest')
306+
expect(button('Copy manifest')).toBeDefined()
307+
expect(button('Continue')).toBeDisabled()
308+
await click('Copy manifest')
309+
expect(mocks.copy).toHaveBeenCalledExactlyOnceWith('{}')
310+
expect(button('Continue')).not.toBeDisabled()
306311
expect(document.querySelector('a[href="https://api.slack.com/apps"]')).toHaveTextContent(
307-
'Create app'
312+
'Open Slack Apps'
308313
)
309314
expect(document.querySelectorAll('input')).toHaveLength(0)
310315
expect(mocks.manifest).toHaveBeenCalledWith('org-1', 'Sim Search')
@@ -370,8 +375,9 @@ describe('Slack Search settings and shared wizard', () => {
370375
)
371376
} else {
372377
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Create Slack app')
378+
await click('Copy manifest')
373379
await click('Continue')
374-
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Client ID')
380+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Bot User OAuth Token')
375381
expect(mocks.install).not.toHaveBeenCalled()
376382
}
377383
}
@@ -386,18 +392,14 @@ describe('Slack Search settings and shared wizard', () => {
386392
expect(button('Continue')).toBeDisabled()
387393
await click('Continue')
388394
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Client ID')
389-
await click('Copy configuration')
395+
await click('Copy manifest')
390396
expect(button('Continue')).not.toBeDisabled()
391397
expect(mocks.copy).toHaveBeenCalledExactlyOnceWith('{}')
392398
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull()
393-
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Configuration copied')
394-
expect(document.querySelector('pre')).toBeNull()
395-
await click('Continue')
396-
expect(
397-
document.querySelectorAll('input[placeholder="Leave blank to keep the saved value"]')
398-
).toHaveLength(3)
399+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Manifest copied')
400+
expect(document.querySelector('details')).not.toHaveAttribute('open')
399401
await click('Continue')
400-
expect(button('Connect app')).toBeDisabled()
402+
expect(button('Continue')).toBeDisabled()
401403
await act(async () => {
402404
const input = document.querySelector<HTMLInputElement>('input[placeholder="xoxb-..."]')!
403405
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
@@ -406,6 +408,10 @@ describe('Slack Search settings and shared wizard', () => {
406408
)
407409
input.dispatchEvent(new Event('input', { bubbles: true }))
408410
})
411+
await click('Continue')
412+
expect(
413+
document.querySelectorAll('input[placeholder="Leave blank to keep the saved value"]')
414+
).toHaveLength(3)
409415
await click('Connect app')
410416
expect(mocks.connect).toHaveBeenCalledWith(
411417
expect.objectContaining({
@@ -423,12 +429,12 @@ describe('Slack Search settings and shared wizard', () => {
423429
mocks.copy.mockRejectedValueOnce(new Error('Clipboard access denied'))
424430
await render(true)
425431
await action('Reconnect')
426-
await click('Copy configuration')
432+
await click('Copy manifest')
427433
expect(document.querySelector('[role="alert"]')).toHaveTextContent('Allow clipboard access')
428-
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).toBeNull()
429-
expect(button('Copy configuration')).toBeDefined()
434+
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull()
435+
expect(button('Copy manifest')).toBeDefined()
430436
expect(button('Continue')).toBeDisabled()
431-
await click('Copy configuration')
437+
await click('Copy manifest')
432438
expect(document.querySelector('[role="alert"]')).toBeNull()
433439
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull()
434440
})
@@ -446,12 +452,81 @@ describe('Slack Search settings and shared wizard', () => {
446452
await render()
447453
await click('Set up')
448454
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Update Slack app')
449-
await click('Copy configuration')
455+
await click('Copy manifest')
450456
expect(mocks.copy).toHaveBeenCalledExactlyOnceWith(
451457
'{"display_information":{"name":"Shared Slack app"}}'
452458
)
453459
expect(document.querySelector('a[href="https://api.slack.com/apps/A2"]')).not.toBeNull()
454-
expect(document.querySelector('pre')).toBeNull()
460+
expect(document.querySelector('details')).not.toHaveAttribute('open')
461+
})
462+
463+
it('keeps the token and credentials when navigating back through setup', async () => {
464+
await render()
465+
await click('Set up')
466+
await click('Copy manifest')
467+
await click('Continue')
468+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Install Slack app')
469+
expect(button('Continue')).toBeDisabled()
470+
async function fill(placeholder: string, value: string) {
471+
await act(async () => {
472+
const input = document.querySelector<HTMLInputElement>(
473+
`input[placeholder="${placeholder}"]`
474+
)!
475+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
476+
input,
477+
value
478+
)
479+
input.dispatchEvent(new Event('input', { bubbles: true }))
480+
})
481+
}
482+
await fill('xoxb-...', 'xoxb-test-token')
483+
await click('Continue')
484+
expect(button('Connect app')).toBeDisabled()
485+
await fill('Paste your Slack app’s client ID', 'client-id')
486+
await fill('Paste your Slack app’s client secret', 'client-secret')
487+
await fill('Paste your Slack app’s signing secret', 'signing-secret')
488+
await click('Back')
489+
expect(document.querySelector('input')).toHaveValue('xoxb-test-token')
490+
await click('Back')
491+
expect(button('Copy manifest')).toBeDefined()
492+
await click('Continue')
493+
await click('Continue')
494+
expect(
495+
document.querySelector('input[placeholder="Paste your Slack app’s client ID"]')
496+
).toHaveValue('client-id')
497+
await click('Connect app')
498+
expect(mocks.connect).toHaveBeenCalledWith(
499+
expect.objectContaining({
500+
botToken: 'xoxb-test-token',
501+
clientId: 'client-id',
502+
clientSecret: 'client-secret',
503+
signingSecret: 'signing-secret',
504+
}),
505+
expect.any(Object)
506+
)
507+
})
508+
509+
it('requires copying a refreshed manifest and blocks cached preparation errors', async () => {
510+
await render()
511+
await click('Set up')
512+
await click('Copy manifest')
513+
const data = { manifest: '{"display_information":{"name":"Updated"}}', existingApp: null }
514+
mocks.manifest.mockReturnValue({ data })
515+
await render()
516+
expect(button('Continue')).toBeDisabled()
517+
await click('Copy manifest')
518+
expect(mocks.copy).toHaveBeenLastCalledWith(data.manifest)
519+
expect(button('Continue')).not.toBeDisabled()
520+
mocks.manifest.mockReturnValue({
521+
data,
522+
error: new Error('Manifest unavailable'),
523+
refetch: mocks.refetch,
524+
})
525+
await render()
526+
expect(button('Copy manifest')).toBeDisabled()
527+
expect(button('Continue')).toBeDisabled()
528+
await click('Retry')
529+
expect(mocks.refetch).toHaveBeenCalledOnce()
455530
})
456531

457532
it('disables the selected connection from the actions menu', async () => {

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,12 +290,12 @@ export function ConnectSlackBotModal({
290290
<Wizard.Step title={isReconnect ? 'Open your app in Slack' : 'Create the app in Slack'}>
291291
<StepCreate manifestJson={manifestJson} reconnect={isReconnect} />
292292
</Wizard.Step>
293+
<Wizard.Step title='Install and paste your Bot Token' canAdvance={botToken.trim().length > 0}>
294+
<StepToken value={botToken} onChange={setBotToken} reconnect={isReconnect} />
295+
</Wizard.Step>
293296
<Wizard.Step title='Paste your Signing Secret' canAdvance={signingSecret.trim().length > 0}>
294297
<StepSecret value={signingSecret} onChange={setSigningSecret} />
295298
</Wizard.Step>
296-
<Wizard.Step title='Install and paste your Bot Token' canAdvance={botToken.trim().length > 0}>
297-
<StepToken value={botToken} onChange={setBotToken} />
298-
</Wizard.Step>
299299
<Wizard.Step title='All set'>
300300
<StepDone
301301
searchOnly={searchOnly}
@@ -521,7 +521,7 @@ function StepCreate({ manifestJson, reconnect }: StepCreateProps) {
521521
<div className='space-y-4'>
522522
<SubStepList>
523523
<SubStep n={1}>
524-
<div>Copy your manifest:</div>
524+
<div>Copy the manifest for your selected permissions:</div>
525525
<div className='mt-2'>
526526
<SlackAppManifest manifest={manifestJson} />
527527
</div>
@@ -543,7 +543,12 @@ function StepCreate({ manifestJson, reconnect }: StepCreateProps) {
543543
workspace.
544544
</SubStep>
545545
<SubStep n={4}>
546-
Paste your manifest, then click <strong>Next</strong> → <strong>Create</strong>.
546+
Select <strong>JSON</strong>, paste the manifest, then click <strong>Next</strong> →{' '}
547+
<strong>Create</strong>.
548+
</SubStep>
549+
<SubStep n={5}>
550+
In <strong>App Manifest</strong>, verify the event <strong>Request URL</strong> if shown.
551+
You can verify it before connecting the bot.
547552
</SubStep>
548553
</SubStepList>
549554
</div>
@@ -576,13 +581,22 @@ function StepSecret({ value, onChange }: SecretStepProps) {
576581
)
577582
}
578583

579-
function StepToken({ value, onChange }: SecretStepProps) {
584+
function StepToken({ value, onChange, reconnect }: SecretStepProps & { reconnect: boolean }) {
580585
return (
581586
<div className='space-y-4'>
582587
<SubStepList>
583588
<SubStep n={1}>
584-
In Slack, open <strong>Install App</strong> → <strong>Install to Workspace</strong> and
585-
authorize.
589+
{reconnect ? (
590+
<>
591+
Open <strong>OAuth &amp; Permissions</strong> in your existing Slack app. Reinstall
592+
only if Slack requests it.
593+
</>
594+
) : (
595+
<>
596+
In Slack, open <strong>OAuth &amp; Permissions</strong> →{' '}
597+
<strong>Install to Workspace</strong> and approve access.
598+
</>
599+
)}
586600
</SubStep>
587601
<SubStep n={2}>
588602
Copy the <strong>Bot User OAuth Token</strong> (starts with <code>xoxb-</code>).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/** @vitest-environment jsdom */
2+
import { act, useState } from 'react'
3+
import { createRoot, type Root } from 'react-dom/client'
4+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
loading: true,
8+
copy: vi.fn(),
9+
}))
10+
vi.mock('@/hooks/use-webhook-management', () => ({
11+
useWebhookManagement: () => ({
12+
webhookUrl: 'https://sim.test/api/webhooks/trigger/block-1',
13+
isLoading: mocks.loading,
14+
}),
15+
}))
16+
vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: () => 'workflow-1' }))
17+
vi.mock('@/stores/workflows/subblock/store', () => ({
18+
useSubBlockStore: (selector: (state: { workflowValues: object }) => unknown) =>
19+
selector({ workflowValues: {} }),
20+
}))
21+
vi.mock(
22+
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value',
23+
() => ({
24+
useSubBlockValue: (_blockId: string, key: string) =>
25+
useState(key === 'botDisplayName' ? 'Test workflow bot' : ''),
26+
})
27+
)
28+
29+
import { SlackSetupWizard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard'
30+
31+
let root: Root
32+
let container: HTMLDivElement
33+
beforeEach(() => {
34+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
35+
vi.stubGlobal('navigator', { clipboard: { writeText: mocks.copy } })
36+
mocks.loading = true
37+
mocks.copy.mockReset().mockResolvedValue(undefined)
38+
container = document.createElement('div')
39+
document.body.appendChild(container)
40+
root = createRoot(container)
41+
})
42+
afterEach(async () => {
43+
await act(async () => root.unmount())
44+
container.remove()
45+
vi.unstubAllGlobals()
46+
})
47+
async function render() {
48+
await act(async () => root.render(<SlackSetupWizard blockId='block-1' />))
49+
}
50+
function button(name: string) {
51+
const element = [...document.querySelectorAll('button')].find(
52+
(button) => button.textContent?.trim() === name
53+
)
54+
expect(element).toBeDefined()
55+
return element!
56+
}
57+
async function click(name: string) {
58+
await act(async () => button(name).click())
59+
}
60+
async function fill(placeholder: string, value: string) {
61+
const input = document.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)!
62+
await act(async () => input.focus())
63+
await act(async () => {
64+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
65+
input.dispatchEvent(new Event('input', { bubbles: true }))
66+
})
67+
}
68+
69+
it('waits for the webhook URL without requiring an early deployment, then copies the current manifest', async () => {
70+
await render()
71+
await click('Set up Slack app')
72+
await click('Next')
73+
expect(button('Copy manifest')).toBeDisabled()
74+
expect(button('Next')).toBeDisabled()
75+
expect(document.body).toHaveTextContent('Loading the webhook URL')
76+
expect(document.body).not.toHaveTextContent('Deploy once')
77+
mocks.loading = false
78+
await render()
79+
await click('Copy manifest')
80+
const manifest = JSON.parse(mocks.copy.mock.calls[0][0])
81+
expect(manifest.display_information.name).toBe('Test workflow bot')
82+
expect(manifest.settings.event_subscriptions.request_url).toBe(
83+
'https://sim.test/api/webhooks/trigger/block-1'
84+
)
85+
})
86+
87+
it('collects the token before the signing secret and retains both when going back', async () => {
88+
mocks.loading = false
89+
await render()
90+
await click('Set up Slack app')
91+
await click('Next')
92+
await click('Next')
93+
expect(button('Next')).toBeDisabled()
94+
await fill('xoxb-...', 'xoxb-test-token')
95+
await click('Next')
96+
expect(button('Next')).toBeDisabled()
97+
await fill('Paste your signing secret', 'test-secret')
98+
await click('Back')
99+
await click('Next')
100+
expect(button('Next')).not.toBeDisabled()
101+
await click('Next')
102+
expect(document.body).toHaveTextContent('save and deploy the workflow with these credentials')
103+
expect(document.body).toHaveTextContent('verify the event Request URL')
104+
expect(document.body).not.toHaveTextContent('automatically')
105+
})

0 commit comments

Comments
 (0)