Skip to content

Commit 8bf2e6e

Browse files
fix(slack-search): preserve existing app configuration updates
1 parent 8fbfd56 commit 8bf2e6e

2 files changed

Lines changed: 81 additions & 13 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
1212
remove: vi.fn(),
1313
install: vi.fn(),
1414
refetch: vi.fn(),
15+
copy: vi.fn(),
1516
removeError: null as Error | null,
1617
}))
1718
vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] }))
@@ -53,6 +54,8 @@ let container: HTMLDivElement
5354
beforeEach(() => {
5455
vi.clearAllMocks()
5556
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
57+
vi.stubGlobal('navigator', { clipboard: { writeText: mocks.copy } })
58+
mocks.copy.mockReset().mockResolvedValue(undefined)
5659
mocks.context.mockReturnValue({ organization: { id: 'org-1' }, viewer: { isAdmin: true } })
5760
mocks.list.mockReturnValue({ data: { installations: [], bots: [] } })
5861
mocks.manifest.mockReturnValue({
@@ -138,7 +141,11 @@ describe('Slack Search settings and shared wizard', () => {
138141
expect(container.textContent).toContain('Enabled')
139142
await action('Reconnect')
140143
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Reconnect Slack Search')
144+
await click('Copy app configuration')
145+
expect(mocks.copy).toHaveBeenCalledExactlyOnceWith('{}')
141146
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull()
147+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Configuration copied')
148+
expect(document.querySelector('pre')).toBeNull()
142149
await click('Continue')
143150
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Leave fields blank')
144151
await click('Continue')
@@ -154,6 +161,40 @@ describe('Slack Search settings and shared wizard', () => {
154161
expect(mocks.install.mock.calls[0][0]).not.toHaveProperty('clientSecret')
155162
})
156163

164+
it('keeps the update action available when clipboard access fails', async () => {
165+
mocks.copy.mockRejectedValueOnce(new Error('Clipboard access denied'))
166+
await render(true)
167+
await action('Reconnect')
168+
await click('Copy app configuration')
169+
expect(document.querySelector('[role="alert"]')).toHaveTextContent('Allow clipboard access')
170+
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).toBeNull()
171+
expect(button('Copy app configuration')).toBeDefined()
172+
await click('Copy app configuration')
173+
expect(document.querySelector('[role="alert"]')).toBeNull()
174+
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull()
175+
})
176+
177+
it('offers the same configuration update for an app shared with Slack sources', async () => {
178+
mocks.manifest.mockReturnValue({
179+
data: {
180+
manifest: '{"display_information":{"name":"Shared Slack app"}}',
181+
existingApp: { appId: 'A2' },
182+
createAppUrl: 'https://api.slack.com/apps',
183+
},
184+
isPending: false,
185+
refetch: mocks.refetch,
186+
})
187+
await render()
188+
await click('Set up')
189+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Update your Slack app')
190+
await click('Copy app configuration')
191+
expect(mocks.copy).toHaveBeenCalledExactlyOnceWith(
192+
'{"display_information":{"name":"Shared Slack app"}}'
193+
)
194+
expect(document.querySelector('a[href="https://api.slack.com/apps/A2"]')).not.toBeNull()
195+
expect(document.querySelector('pre')).toBeNull()
196+
})
197+
157198
it('disables the selected connection from the actions menu', async () => {
158199
await render(true)
159200
await action('Disable')

apps/sim/components/integrations/slack-search-setup-wizard.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ChipModalField,
1111
ChipModalFooter,
1212
ChipModalHeader,
13+
writeTextToClipboard,
1314
} from '@sim/emcn'
1415
import { SlackIcon } from '@/components/icons'
1516
import {
@@ -42,11 +43,26 @@ export function SlackSearchSetupWizard({
4243
const [clientId, setClientId] = useState('')
4344
const [clientSecret, setClientSecret] = useState('')
4445
const [signingSecret, setSigningSecret] = useState('')
45-
const error = prepare.error ?? oauth.error
46+
const [configurationCopied, setConfigurationCopied] = useState(false)
47+
const [copyError, setCopyError] = useState<Error | null>(null)
48+
const error = prepare.error ?? oauth.error ?? copyError
4649
const busy = oauth.isPending
4750
const stepNumber = step === 'manifest' ? 1 : step === 'credentials' ? 2 : 3
4851
const configuredAppId = appId ?? prepare.data?.existingApp?.appId
4952

53+
async function copyConfiguration() {
54+
if (!prepare.data) throw new Error('Slack app configuration is not ready')
55+
setCopyError(null)
56+
try {
57+
await writeTextToClipboard(prepare.data.manifest)
58+
setConfigurationCopied(true)
59+
} catch {
60+
setCopyError(
61+
new Error('Could not copy the app configuration. Allow clipboard access and try again.')
62+
)
63+
}
64+
}
65+
5066
function advance() {
5167
if (step === 'manifest') {
5268
setStep('credentials')
@@ -102,19 +118,30 @@ export function SlackSearchSetupWizard({
102118
{step === 'manifest' && prepare.data && (
103119
<ChipModalField
104120
type='custom'
105-
title={installationId ? 'Update your Slack app' : 'Create your Slack app'}
121+
title={configuredAppId ? 'Update your Slack app' : 'Create your Slack app'}
122+
hint={
123+
configuredAppId
124+
? configurationCopied
125+
? 'Configuration copied. In Slack, open App Manifest, select JSON, replace the configuration, and save your changes before continuing.'
126+
: 'Copy the updated configuration, then open your app in Slack to apply it.'
127+
: undefined
128+
}
106129
>
107-
<ChipLink
108-
href={
109-
configuredAppId
110-
? `https://api.slack.com/apps/${encodeURIComponent(configuredAppId)}`
111-
: prepare.data.createAppUrl
112-
}
113-
target='_blank'
114-
rel='noopener noreferrer'
115-
>
116-
{configuredAppId ? 'Open Slack app settings' : 'Create app in Slack'}
117-
</ChipLink>
130+
{configuredAppId && !configurationCopied ? (
131+
<Chip onClick={() => void copyConfiguration()}>Copy app configuration</Chip>
132+
) : (
133+
<ChipLink
134+
href={
135+
configuredAppId
136+
? `https://api.slack.com/apps/${encodeURIComponent(configuredAppId)}`
137+
: prepare.data.createAppUrl
138+
}
139+
target='_blank'
140+
rel='noopener noreferrer'
141+
>
142+
{configuredAppId ? 'Open Slack app settings' : 'Create app in Slack'}
143+
</ChipLink>
144+
)}
118145
</ChipModalField>
119146
)}
120147
{step === 'credentials' && (

0 commit comments

Comments
 (0)