Skip to content

Commit 7a8f39a

Browse files
authored
fix(connectors): explain disabled settings with tooltips (#7981)
* fix(connectors): explain disabled settings with tooltips * fix(settings): preserve accessible disabled-action explanations
1 parent ed437fc commit 7a8f39a

11 files changed

Lines changed: 117 additions & 13 deletions

File tree

‎apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -988,7 +988,8 @@ describe('member content credentials in real add and edit dialogs', () => {
988988
await chooseSyncFrequency('Manual only')
989989
expect(document.body.textContent).toContain('Documents become unavailable after 24 hours')
990990
await chooseSyncFrequency('Every hour')
991-
expect(document.body.textContent).toContain('Permissions are checked on every sync.')
991+
expect(document.body.textContent).not.toContain('Documents become unavailable after 24 hours')
992+
expect(document.body.textContent).not.toContain('Permissions are checked on every sync.')
992993
})
993994

994995
it('saves source settings without changing a dedicated indexing account', async () => {

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,26 @@ describe('connection method selection', () => {
123123
expect(container.textContent).toContain(label)
124124
expect(container.textContent).not.toContain('Add a new connection')
125125
expect(container.querySelector('[role="radiogroup"]')).toBeNull()
126-
expect(container.querySelector('button')).toBeNull()
126+
const trigger = container.querySelector('button')!
127+
expect(trigger).toBeDisabled()
128+
expect(document.querySelector('[role="tooltip"]')).toBeNull()
129+
await act(async () => {
130+
trigger.parentElement!.dispatchEvent(
131+
new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 })
132+
)
133+
})
134+
expect(document.querySelector('[role="tooltip"]')).toHaveTextContent(
135+
'Add a new connection to change the sync method.'
136+
)
137+
await act(async () => {
138+
trigger.parentElement!.dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))
139+
})
140+
expect(document.querySelector('[role="tooltip"]')).toBeNull()
141+
expect(trigger.parentElement!.tabIndex).toBe(0)
142+
await act(async () => trigger.parentElement!.focus())
143+
expect(document.querySelector('[role="tooltip"]')).toHaveTextContent(
144+
'Add a new connection to change the sync method.'
145+
)
127146
expect(onChange).not.toHaveBeenCalled()
128147
})
129148

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
ChipButtonGroup,
66
ChipButtonGroupItem,
77
ChipCombobox,
8+
ChipDropdown,
89
ChipLink,
910
ChipModalField,
1011
type ComboboxOption,
12+
Tooltip,
1113
} from '@sim/emcn'
1214
import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors'
1315
import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope'
@@ -164,7 +166,22 @@ export function ConnectorAccessField({
164166
}
165167
>
166168
<div className='flex flex-col gap-2'>
167-
{slackSetupOnly ? null : !lockAccessMode && showModeSelector ? (
169+
{slackSetupOnly ? null : lockAccessMode ? (
170+
<Tooltip.Root>
171+
<Tooltip.Trigger asChild tabIndex={0}>
172+
<span className='inline-flex w-fit cursor-not-allowed'>
173+
<ChipDropdown
174+
aria-label={`Sync using: ${currentMode?.label ?? 'Unavailable'}`}
175+
value={value.accessMode}
176+
options={visibleModes.map(({ mode, label }) => ({ value: mode, label }))}
177+
disabled
178+
className='pointer-events-none w-fit'
179+
/>
180+
</span>
181+
</Tooltip.Trigger>
182+
<Tooltip.Content>Add a new connection to change the sync method.</Tooltip.Content>
183+
</Tooltip.Root>
184+
) : showModeSelector ? (
168185
<ChipButtonGroup
169186
value={value.accessMode}
170187
disabled={disabled}

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/consts.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function connectorSyncFrequencyHint(
1212
return 'Content and permissions update only when you sync. Documents become unavailable after 24 hours without a successful permission check.'
1313
}
1414
if (effectiveConnectorSyncIntervalMinutes(accessMode, syncInterval) === syncInterval) {
15-
return 'Permissions are checked on every sync.'
15+
return undefined
1616
}
1717
return accessMode === 'members' && hasContentCredential
1818
? 'Content follows this schedule. Member permissions are checked every hour.'

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.test.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,14 @@ describe('connector settings service-account choices', () => {
187187
})
188188
}
189189

190+
it('announces blocked saves without showing persistent helper text', async () => {
191+
const reason = 'Wait for the current sync to finish before saving.'
192+
await render(confluenceConnectorMeta, { saveBlockedReason: reason })
193+
const status = container.querySelector('[role="status"]')
194+
expect(status).toHaveClass('sr-only')
195+
expect(status).toHaveTextContent(reason)
196+
})
197+
190198
async function openAccountChoices() {
191199
const dropdown = container.querySelector<HTMLElement>('[role="combobox"]')
192200
if (!dropdown) throw new Error('Missing indexing-account selector')

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ export function ConnectorSettingsFields({
465465
title='Account for browsing'
466466
hint={
467467
isSearchIndex
468-
? 'Used to browse available content. Each person connects separately from Integrations to sync their Search content.'
468+
? 'Members sync with their own accounts connected in Integrations.'
469469
: undefined
470470
}
471471
>
@@ -553,11 +553,9 @@ export function ConnectorSettingsFields({
553553
</ChipModalField>
554554
)}
555555

556-
{saveBlockedReason && (
557-
<p role='status' className='px-2 text-[var(--text-muted)] text-caption'>
558-
{saveBlockedReason}
559-
</p>
560-
)}
556+
<p role='status' className='sr-only'>
557+
{saveBlockedReason}
558+
</p>
561559
<ChipModalError>{error}</ChipModalError>
562560
</>
563561
)

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export function EditConnectorModal({
108108
label: form.saving ? 'Saving…' : 'Save',
109109
onClick: form.save,
110110
disabled: !form.canSave,
111+
disabledTooltip: form.saveBlockedReason,
111112
}}
112113
/>
113114
)}

‎apps/sim/components/settings/settings-header-shell.test.tsx‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,32 @@ function clickChip(label: string) {
6868
}
6969

7070
describe('SettingsHeaderShell action routing', () => {
71+
it('makes disabled Save explanations keyboard reachable without enabling Save', () => {
72+
const onSave = vi.fn()
73+
const reason = 'Wait for the current sync to finish before saving.'
74+
renderHeader(
75+
saveDiscardActions({
76+
dirty: true,
77+
saving: false,
78+
saveDisabled: true,
79+
saveTooltip: reason,
80+
onSave,
81+
onDiscard: vi.fn(),
82+
})
83+
)
84+
const save = [...container.querySelectorAll('button')].find(
85+
(button) => button.textContent === 'Save'
86+
)!
87+
const trigger = save.parentElement!
88+
expect(save.disabled).toBe(true)
89+
expect(trigger.tabIndex).toBe(0)
90+
act(() => trigger.focus())
91+
expect(document.activeElement).toBe(trigger)
92+
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(reason)
93+
act(() => save.click())
94+
expect(onSave).not.toHaveBeenCalled()
95+
})
96+
7197
it('renders Delete before Discard and Save even though the array lists it last', () => {
7298
const actions: SettingsAction[] = [
7399
...saveDiscardActions({ dirty: true, saving: false, onSave: vi.fn(), onDiscard: vi.fn() }),

‎apps/sim/components/settings/settings-header.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ export function SettingsActionChip({
203203
if (!action.tooltip) return chip
204204
return (
205205
<Tooltip.Root>
206-
<Tooltip.Trigger asChild>
206+
<Tooltip.Trigger asChild tabIndex={action.disabled ? 0 : undefined}>
207207
<span className='inline-flex'>{chip}</span>
208208
</Tooltip.Trigger>
209209
<Tooltip.Content>{action.tooltip}</Tooltip.Content>

‎packages/emcn/src/components/chip-modal/chip-modal.test.tsx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,40 @@ describe('ChipModalField file actions', () => {
341341
describe('ChipModal default actions', () => {
342342
beforeEach(makeElementsVisible)
343343

344+
it.each(['save', 'confirm'] as const)(
345+
'makes a disabled %s explanation reachable without enabling the action',
346+
(variant) => {
347+
const onClick = vi.fn()
348+
const action = {
349+
label: 'Save',
350+
disabled: true,
351+
disabledTooltip: 'Wait for the current sync to finish before saving.',
352+
onClick,
353+
}
354+
mount(
355+
variant === 'confirm' ? (
356+
<ChipConfirmModal open onOpenChange={() => {}} title='Save settings' confirm={action} />
357+
) : (
358+
<ChipModal open onOpenChange={() => {}} srTitle='Save settings'>
359+
<ChipModalHeader onClose={() => {}}>Save settings</ChipModalHeader>
360+
<ChipModalFooter onCancel={() => {}} primaryAction={action} />
361+
</ChipModal>
362+
)
363+
)
364+
365+
const save = buttonByText('Save')
366+
const trigger = save.parentElement!
367+
expect(save.disabled).toBe(true)
368+
expect(trigger.tabIndex).toBe(0)
369+
act(() => trigger.focus())
370+
expect(document.activeElement).toBe(trigger)
371+
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(action.disabledTooltip)
372+
pressEnter(trigger)
373+
act(() => save.click())
374+
expect(onClick).not.toHaveBeenCalled()
375+
}
376+
)
377+
344378
it('fails safe to the dismiss decision in a confirmation', () => {
345379
mount(
346380
<ChipConfirmModal

0 commit comments

Comments
 (0)