Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
type ChipDropdownOption,
ChipInput,
ChipModalField,
SecretInput,
Wizard,
} from '@sim/emcn'
import { Loader, Plus, Trash } from '@sim/emcn/icons'
Expand All @@ -19,6 +18,11 @@ import { SlackIcon } from '@/components/icons'
import { SlackAppManifest } from '@/components/integrations/slack-app-manifest'
import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
buildSlackAppCreationUrl,
getSlackAppNameError,
SLACK_APP_CREATION_URL_MAX_LENGTH,
} from '@/lib/integrations/slack-manifest'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import {
useCreateScopedCredential,
Expand Down Expand Up @@ -167,6 +171,7 @@ export function ConnectSlackBotModal({
// window.location.origin) so Slack's servers can reach it.
const requestUrl = buildSlackCustomBotRequestUrl(credentialId)

const nameError = isReconnect ? null : getSlackAppNameError(appName)
const descriptionError = getAgentDescriptionError(appDescription)
const slashCommandsError = searchOnly || isReconnect ? null : getSlashCommandsError(slashCommands)
const manifestConfigurationError = descriptionError ?? slashCommandsError
Expand All @@ -192,7 +197,7 @@ export function ConnectSlackBotModal({
),
...(managedUserAuthorization ? { managedUserAuthorization } : {}),
})
return JSON.stringify(manifest, null, 2)
return JSON.stringify(manifest)
}, [
isReconnect,
manifestConfigurationError,
Expand All @@ -204,6 +209,12 @@ export function ConnectSlackBotModal({
searchOnly,
])

const createAppUrl = buildSlackAppCreationUrl(manifestJson)
const creationUrlError =
createAppUrl.length > SLACK_APP_CREATION_URL_MAX_LENGTH
? 'This app configuration is too large to open in Slack. Shorten or remove slash commands.'
: null

const capabilityIds = [...selected]
const setCapabilityIds = (next: string[]) => setSelected(new Set(next))

Expand Down Expand Up @@ -270,32 +281,42 @@ export function ConnectSlackBotModal({
fallback, which collides for a second bot in the same workspace. */}
<Wizard.Step
title={searchOnly ? 'Name your Slack app' : 'Configure your bot'}
canAdvance={appName.trim().length > 0 && !descriptionError && !slashCommandsError}
canAdvance={
appName.trim().length > 0 &&
!nameError &&
!manifestConfigurationError &&
!creationUrlError
}
>
<StepConfigure
searchOnly={searchOnly}
reconnect={isReconnect}
appName={appName}
nameError={appName ? nameError : null}
onAppNameChange={setAppName}
appDescription={appDescription}
onAppDescriptionChange={setAppDescription}
descriptionError={descriptionError}
slashCommands={slashCommands}
onSlashCommandsChange={setSlashCommands}
slashCommandsError={slashCommandsError}
slashCommandsError={slashCommandsError ?? creationUrlError}
capabilityIds={capabilityIds}
onCapabilityIdsChange={setCapabilityIds}
/>
</Wizard.Step>
<Wizard.Step title={isReconnect ? 'Open your app in Slack' : 'Create the app in Slack'}>
<StepCreate manifestJson={manifestJson} reconnect={isReconnect} />
<StepCreate
manifestJson={manifestJson}
createAppUrl={createAppUrl}
reconnect={isReconnect}
/>
</Wizard.Step>
<Wizard.Step title='Install and paste your Bot Token' canAdvance={botToken.trim().length > 0}>
<StepToken value={botToken} onChange={setBotToken} reconnect={isReconnect} />
</Wizard.Step>
<Wizard.Step title='Paste your Signing Secret' canAdvance={signingSecret.trim().length > 0}>
<StepSecret value={signingSecret} onChange={setSigningSecret} />
</Wizard.Step>
<Wizard.Step title='Install and paste your Bot Token' canAdvance={botToken.trim().length > 0}>
<StepToken value={botToken} onChange={setBotToken} />
</Wizard.Step>
<Wizard.Step title='All set'>
<StepDone
searchOnly={searchOnly}
Expand Down Expand Up @@ -337,6 +358,7 @@ interface StepConfigureProps {
searchOnly: boolean
reconnect: boolean
appName: string
nameError: string | null
onAppNameChange: (next: string) => void
appDescription: string
onAppDescriptionChange: (next: string) => void
Expand All @@ -351,6 +373,7 @@ function StepConfigure({
searchOnly,
reconnect,
appName,
nameError,
onAppNameChange,
appDescription,
onAppDescriptionChange,
Expand All @@ -372,6 +395,7 @@ function StepConfigure({
value={appName}
onChange={onAppNameChange}
placeholder={DEFAULT_APP_NAME}
error={nameError}
/>
<ChipModalField
type='input'
Expand Down Expand Up @@ -491,9 +515,10 @@ function SlashCommandsEditor({ commands, onChange, error }: SlashCommandsEditorP

interface StepCreateProps {
manifestJson: string
createAppUrl: string
reconnect: boolean
}
function StepCreate({ manifestJson, reconnect }: StepCreateProps) {
function StepCreate({ manifestJson, createAppUrl, reconnect }: StepCreateProps) {
if (reconnect) {
return (
<SubStepList>
Expand Down Expand Up @@ -521,29 +546,13 @@ function StepCreate({ manifestJson, reconnect }: StepCreateProps) {
<div className='space-y-4'>
<SubStepList>
<SubStep n={1}>
<div>Copy your manifest:</div>
<div>Open Slack with the manifest for your selected permissions already filled in:</div>
<div className='mt-2'>
<SlackAppManifest manifest={manifestJson} />
<SlackAppManifest manifest={manifestJson} createAppUrl={createAppUrl} />
</div>
</SubStep>
<SubStep n={2}>
Open the{' '}
<a
href='https://api.slack.com/apps'
target='_blank'
rel='noopener noreferrer'
className='text-[var(--brand-secondary)] underline underline-offset-2'
>
Slack Apps page
</a>
.
</SubStep>
<SubStep n={3}>
Click <strong>Create New App</strong> → <strong>From a manifest</strong> and pick your
workspace.
</SubStep>
<SubStep n={4}>
Paste your manifest, then click <strong>Next</strong> → <strong>Create</strong>.
Select your workspace, review the configuration, then click <strong>Create</strong>.
</SubStep>
</SubStepList>
</div>
Expand Down Expand Up @@ -576,13 +585,22 @@ function StepSecret({ value, onChange }: SecretStepProps) {
)
}

function StepToken({ value, onChange }: SecretStepProps) {
function StepToken({ value, onChange, reconnect }: SecretStepProps & { reconnect: boolean }) {
return (
<div className='space-y-4'>
<SubStepList>
<SubStep n={1}>
In Slack, open <strong>Install App</strong> → <strong>Install to Workspace</strong> and
authorize.
{reconnect ? (
<>
Open <strong>OAuth &amp; Permissions</strong> in your existing Slack app. Reinstall
only if Slack requests it.
</>
) : (
<>
In Slack, open <strong>OAuth &amp; Permissions</strong> →{' '}
<strong>Install to Workspace</strong> and approve access.
</>
)}
</SubStep>
<SubStep n={2}>
Copy the <strong>Bot User OAuth Token</strong> (starts with <code>xoxb-</code>).
Expand All @@ -602,9 +620,15 @@ interface SecretFieldProps {
}
function SecretField({ label, value, onChange, placeholder }: SecretFieldProps) {
return (
<ChipModalField type='custom' title={label}>
<SecretInput value={value} onChange={onChange} placeholder={placeholder} />
</ChipModalField>
<ChipModalField
type='input'
inputType='password'
title={label}
value={value}
onChange={onChange}
placeholder={placeholder}
autoComplete='off'
/>
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/** @vitest-environment jsdom */
import { act, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
loading: true,
}))
vi.mock('@/hooks/use-webhook-management', () => ({
useWebhookManagement: () => ({
webhookUrl: 'https://sim.test/api/webhooks/trigger/block-1',
isLoading: mocks.loading,
}),
}))
vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: () => 'workflow-1' }))
vi.mock('@/stores/workflows/subblock/store', () => ({
useSubBlockStore: (selector: (state: { workflowValues: object }) => unknown) =>
selector({ workflowValues: {} }),
}))
vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value',
() => ({
useSubBlockValue: (_blockId: string, key: string) =>
useState(key === 'botDisplayName' ? 'Test workflow bot' : ''),
})
)

import { SlackSetupWizard } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-setup-wizard/slack-setup-wizard'

let root: Root
let container: HTMLDivElement
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
mocks.loading = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
vi.unstubAllGlobals()
vi.useRealTimers()
})
async function render() {
await act(async () => root.render(<SlackSetupWizard blockId='block-1' />))
}
function button(name: string) {
const element = [...document.querySelectorAll('button')].find(
(button) => button.textContent?.trim() === name
)
expect(element).toBeDefined()
return element!
}
async function click(name: string) {
await act(async () => button(name).click())
}
async function fill(placeholder: string, value: string) {
const input = document.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)!
await act(async () => input.focus())
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}

it('uses the existing default name when the bot name is cleared', async () => {
mocks.loading = false
await render()
await click('Set up Slack app')
await fill('Sim Workflow Bot', '')
expect(button('Next')).not.toBeDisabled()
await click('Next')
const link = document.querySelector<HTMLAnchorElement>('a[href*="manifest_json"]')!
const manifest = JSON.parse(new URL(link.href).searchParams.get('manifest_json')!)
expect(manifest.display_information.name).toBe('Sim Workflow Bot')
})
Loading
Loading