Skip to content

Commit 9eee8e4

Browse files
fix(slack): show shared app installation beside custom bots
1 parent 17e983e commit 9eee8e4

3 files changed

Lines changed: 171 additions & 10 deletions

File tree

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

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
1414
refetch: vi.fn(),
1515
copy: vi.fn(),
1616
removeError: null as Error | null,
17+
installError: null as Error | null,
1718
}))
1819
vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] }))
1920
vi.mock('@/components/settings/settings-panel', () => ({
@@ -32,7 +33,12 @@ vi.mock('@/hooks/queries/slack-search', () => ({
3233
error: mocks.removeError,
3334
reset: vi.fn(),
3435
}),
35-
useStartSlackSearchOAuth: () => ({ mutate: mocks.install, isPending: false, reset: vi.fn() }),
36+
useStartSlackSearchOAuth: () => ({
37+
mutate: mocks.install,
38+
isPending: false,
39+
error: mocks.installError,
40+
reset: vi.fn(),
41+
}),
3642
}))
3743

3844
import { OrganizationSearchSlack } from '@/app/o/[organizationId]/settings/components/organization-search-slack'
@@ -41,6 +47,7 @@ const installation: SlackSearchInstallationView = {
4147
id: 'installation-1',
4248
credentialId: 'credential-1',
4349
appId: 'A1',
50+
appKind: 'custom',
4451
teamId: 'T1',
4552
teamName: 'Test workspace',
4653
enabled: true,
@@ -57,13 +64,16 @@ beforeEach(() => {
5764
vi.stubGlobal('navigator', { clipboard: { writeText: mocks.copy } })
5865
mocks.copy.mockReset().mockResolvedValue(undefined)
5966
mocks.context.mockReturnValue({ organization: { id: 'org-1' }, viewer: { isAdmin: true } })
60-
mocks.list.mockReturnValue({ data: { installations: [], bots: [] } })
67+
mocks.list.mockReturnValue({
68+
data: { sharedAppAvailable: false, installations: [], bots: [] },
69+
})
6170
mocks.manifest.mockReturnValue({
6271
data: { manifest: '{}', existingApp: null, createAppUrl: 'https://api.slack.com/apps' },
6372
isPending: false,
6473
refetch: mocks.refetch,
6574
})
6675
mocks.removeError = null
76+
mocks.installError = null
6777
container = document.createElement('div')
6878
document.body.appendChild(container)
6979
root = createRoot(container)
@@ -77,6 +87,7 @@ async function render(installed = false) {
7787
if (installed) {
7888
mocks.list.mockReturnValue({
7989
data: {
90+
sharedAppAvailable: false,
8091
installations: [installation],
8192
bots: [{ id: 'credential-1', displayName: 'Sim Search' }],
8293
},
@@ -85,7 +96,8 @@ async function render(installed = false) {
8596
await act(async () => root.render(<OrganizationSearchSlack />))
8697
}
8798
function button(label: string) {
88-
const element = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(
99+
const scope = document.querySelector('[role="dialog"]') ?? document
100+
const element = Array.from(scope.querySelectorAll<HTMLButtonElement>('button')).find(
89101
(element) => element.textContent?.trim() === label
90102
)
91103
expect(element, label).toBeDefined()
@@ -107,6 +119,116 @@ async function action(label: string) {
107119
}
108120

109121
describe('Slack Search settings and shared wizard', () => {
122+
it.each([
123+
{ state: 'no bots', installations: [] },
124+
{ state: 'custom bots', installations: [installation] },
125+
])(
126+
'installs the official app explicitly with $state and a custom source app',
127+
async ({ installations }) => {
128+
mocks.list.mockReturnValue({
129+
data: { sharedAppAvailable: true, installations, bots: [] },
130+
})
131+
mocks.manifest.mockReturnValue({
132+
data: {
133+
manifest: '{}',
134+
existingApp: { appId: 'A1', teamId: 'T1' },
135+
sharedAppId: 'A_SHARED',
136+
createAppUrl: 'https://api.slack.com/apps',
137+
},
138+
})
139+
await render()
140+
await click('Install Sim Search')
141+
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Install Sim Search')
142+
expect(document.querySelectorAll('input')).toHaveLength(0)
143+
expect(mocks.install).not.toHaveBeenCalled()
144+
await click('Install Sim Search')
145+
expect(mocks.install).toHaveBeenCalledExactlyOnceWith(
146+
{
147+
organizationId: 'org-1',
148+
installationId: undefined,
149+
name: 'Sim Search',
150+
description: expect.any(String),
151+
mode: 'shared',
152+
},
153+
expect.any(Object)
154+
)
155+
mocks.installError = new Error(
156+
'Remove the previous Slack source configuration before switching apps; members must reconnect'
157+
)
158+
await render()
159+
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
160+
'Remove the previous Slack source configuration'
161+
)
162+
expect(mocks.configure).not.toHaveBeenCalled()
163+
expect(mocks.remove).not.toHaveBeenCalled()
164+
}
165+
)
166+
167+
it('reconnects an installed official app without offering a duplicate installation', async () => {
168+
mocks.list.mockReturnValue({
169+
data: {
170+
sharedAppAvailable: true,
171+
installations: [{ ...installation, appId: 'A_SHARED', appKind: 'shared' }],
172+
bots: [{ id: 'credential-1', displayName: 'Sim Search' }],
173+
},
174+
})
175+
mocks.manifest.mockReturnValue({ data: { sharedAppId: 'A_SHARED', existingApp: null } })
176+
await render()
177+
expect(container).not.toHaveTextContent('Install Sim Search')
178+
await action('Reconnect')
179+
await click('Install Sim Search')
180+
expect(mocks.install).toHaveBeenCalledWith(
181+
expect.objectContaining({ mode: 'shared', installationId: installation.id }),
182+
expect.any(Object)
183+
)
184+
})
185+
186+
it('does not switch to custom setup when shared installation becomes unavailable', async () => {
187+
mocks.list.mockReturnValue({
188+
data: { sharedAppAvailable: true, installations: [installation], bots: [] },
189+
})
190+
mocks.manifest.mockReturnValue({
191+
data: { sharedAppId: null, existingApp: null },
192+
refetch: mocks.refetch,
193+
})
194+
await render()
195+
await click('Install Sim Search')
196+
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
197+
'Sim Search installation is unavailable'
198+
)
199+
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('Create Slack app')
200+
expect(button('Install Sim Search')).toBeDisabled()
201+
expect(mocks.install).not.toHaveBeenCalled()
202+
await click('Retry')
203+
expect(mocks.refetch).toHaveBeenCalledOnce()
204+
})
205+
206+
it('allows retrying shared setup after a preparation error with cached data', async () => {
207+
mocks.list.mockReturnValue({
208+
data: { sharedAppAvailable: true, installations: [installation], bots: [] },
209+
})
210+
mocks.manifest.mockReturnValue({
211+
data: { sharedAppId: 'A_SHARED', existingApp: null },
212+
error: new Error('Could not load Slack setup'),
213+
refetch: mocks.refetch,
214+
})
215+
await render()
216+
await click('Install Sim Search')
217+
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
218+
'Could not load Slack setup'
219+
)
220+
expect(button('Install Sim Search')).toBeDisabled()
221+
await click('Retry')
222+
expect(mocks.refetch).toHaveBeenCalledOnce()
223+
expect(mocks.install).not.toHaveBeenCalled()
224+
})
225+
226+
it('does not show official installation when it is unavailable', async () => {
227+
await render(true)
228+
expect(container).not.toHaveTextContent('Install Sim Search')
229+
expect(container).toHaveTextContent('Open in Slack')
230+
})
231+
110232
it('starts with one setup action and a Slack app link, with no manifest preview or form', async () => {
111233
await render()
112234
expect(container.querySelectorAll('button')).toHaveLength(1)

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function OrganizationSearchSlack() {
3535
slackSetupResultParam.parser
3636
)
3737
const [wizard, setWizard] = useState<{
38+
mode?: 'custom' | 'shared'
3839
installationId?: string
3940
appId?: string
4041
initialName?: string
@@ -43,6 +44,10 @@ export function OrganizationSearchSlack() {
4344
if (!viewer.isAdmin) return null
4445
const busy = configure.isPending || remove.isPending
4546
const bots = installations.data?.bots ?? []
47+
const canInstallSharedApp =
48+
!installations.error &&
49+
installations.data?.sharedAppAvailable &&
50+
!installations.data.installations.some((installation) => installation.appKind === 'shared')
4651

4752
return (
4853
<SettingsPanel>
@@ -53,7 +58,16 @@ export function OrganizationSearchSlack() {
5358
<Chip onClick={() => void setSetupResult(null)}>Dismiss</Chip>
5459
</div>
5560
)}
56-
<SettingsSection label='Connection'>
61+
<SettingsSection
62+
label='Connection'
63+
action={
64+
canInstallSharedApp && Boolean(installations.data?.installations.length) ? (
65+
<Chip variant='primary' disabled={busy} onClick={() => setWizard({ mode: 'shared' })}>
66+
Install Sim Search
67+
</Chip>
68+
) : undefined
69+
}
70+
>
5771
{installations.error ? (
5872
<SettingsQueryErrorState
5973
error={installations.error}
@@ -72,7 +86,10 @@ export function OrganizationSearchSlack() {
7286
title='Slack'
7387
description='Connect your workspace to ask questions in Slack.'
7488
trailing={
75-
<Chip variant='primary' onClick={() => setWizard({})}>
89+
<Chip
90+
variant='primary'
91+
onClick={() => setWizard(canInstallSharedApp ? { mode: 'shared' } : {})}
92+
>
7693
{installations.data.sharedAppAvailable ? 'Install Sim Search' : 'Set up'}
7794
</Chip>
7895
}
@@ -127,6 +144,7 @@ export function OrganizationSearchSlack() {
127144
disabled: busy,
128145
onSelect: () =>
129146
setWizard({
147+
mode: installation.appKind,
130148
installationId: installation.id,
131149
appId: installation.appId,
132150
initialName: name,

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useSlackSearchManifest, useStartSlackSearchOAuth } from '@/hooks/querie
2020

2121
interface SlackSearchSetupWizardProps {
2222
organizationId: string
23+
mode?: 'custom' | 'shared'
2324
installationId?: string
2425
appId?: string
2526
initialName?: string
@@ -29,6 +30,7 @@ interface SlackSearchSetupWizardProps {
2930
/** App creation, credentials, and consent are one organization-specific setup flow. */
3031
export function SlackSearchSetupWizard({
3132
organizationId,
33+
mode,
3234
installationId,
3335
appId,
3436
initialName,
@@ -61,9 +63,12 @@ export function SlackSearchSetupWizard({
6163
}
6264
}
6365

64-
const shared = Boolean(
65-
prepare.data?.sharedAppId && (!configuredAppId || configuredAppId === prepare.data.sharedAppId)
66-
)
66+
const shared = mode
67+
? mode === 'shared'
68+
: Boolean(
69+
prepare.data?.sharedAppId &&
70+
(!configuredAppId || configuredAppId === prepare.data.sharedAppId)
71+
)
6772

6873
function installShared() {
6974
oauth.mutate(
@@ -153,13 +158,29 @@ export function SlackSearchSetupWizard({
153158
<p className='px-2 text-[var(--text-secondary)] text-sm'>
154159
Choose your Slack workspace and approve Sim Search.
155160
</p>
156-
<ChipModalError>{error?.message}</ChipModalError>
161+
<ChipModalError>
162+
{error?.message ??
163+
(!prepare.data.sharedAppId
164+
? 'Sim Search installation is unavailable. Try again.'
165+
: null)}
166+
</ChipModalError>
157167
</ChipModalBody>
158168
<ChipModalFooter
159169
onCancel={onClose}
170+
secondaryActions={
171+
prepare.error || !prepare.data.sharedAppId
172+
? [
173+
{
174+
label: 'Retry',
175+
onClick: () => void prepare.refetch(),
176+
disabled: prepare.isFetching,
177+
},
178+
]
179+
: undefined
180+
}
160181
primaryAction={{
161182
label: busy ? 'Connecting…' : 'Install Sim Search',
162-
disabled: busy,
183+
disabled: busy || !prepare.data.sharedAppId || Boolean(prepare.error),
163184
onClick: installShared,
164185
}}
165186
/>

0 commit comments

Comments
 (0)