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 @@ -25,6 +25,8 @@ export const PATCH = defineInternalJsonRoute({
knowledgeBaseId: params.id,
accessMode: body.accessMode,
credentialId: body.credentialId,
sourceConfig: body.sourceConfig,
syncIntervalMinutes: body.syncIntervalMinutes,
resolveBillingAttribution: (workspaceId: string) =>
resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId),
source: 'ui' as const,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope'
}),
}))
vi.mock('@/hooks/queries/kb/connectors', () => ({
isConnectorSyncingOrPending: (row: {
status: string
accessMode?: string
memberSyncStatus?: string
}) =>
['pending', 'syncing'].includes(row.status) ||
['pending', 'running'].includes(row.memberSyncStatus ?? ''),
useSearchIndex: (
scope: { workspaceId?: string; organizationId?: string },
options: { enabled: boolean }
Expand Down Expand Up @@ -1367,10 +1374,10 @@ describe('administrator source prerequisites in real connector dialogs', () => {
(node) => node.textContent?.trim() === replacement.name
)!
await act(async () => option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
expect(button('Save')).toBeDisabled()
expect(button('Change service account')).toBeEnabled()
expect(button('Save')).toBeEnabled()
expect(document.body.textContent).not.toContain('Change service account')

await click(button('Change service account'))
await click(button('Save'))

expect(mocks.applyAccess).toHaveBeenCalledExactlyOnceWith(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ vi.mock('@/hooks/use-oauth-return', () => ({ useOAuthReturnForKBConnectors: vi.f
vi.mock('@/hooks/queries/kb/connectors', () => ({
useSearchIndex: mocks.index,
useConnectorDetail: mocks.detail,
isConnectorSyncingOrPending: () => false,
isConnectorSyncingOrPending: (row: ConnectorData) =>
row.status === 'syncing' || row.status === 'pending',
}))
vi.mock('@/hooks/queries/search-integrations', () => ({
useSearchIntegrations: mocks.integrations,
Expand Down Expand Up @@ -187,6 +188,19 @@ describe('organization source detail navigation', () => {
expect(button, `Missing ${text}`).toBeTruthy()
await act(async () => button!.click())
}

it('passes live sync status to the form without replacing its settings baseline', async () => {
await render('?view=settings')
const baseline = mocks.form.mock.lastCall![0].connector
mocks.dirty = true
mocks.detail.mockReturnValue({ data: { ...connector, status: 'syncing' } })
await render('?view=settings')
expect(mocks.form.mock.lastCall![0]).toMatchObject({ connector: baseline, syncing: true })

mocks.detail.mockReturnValue({ data: { ...connector, status: 'active' } })
await render('?view=settings')
expect(mocks.form.mock.lastCall![0]).toMatchObject({ connector: baseline, syncing: false })
})
it.each(['documents', 'settings', 'history'])(
'replaces the removed connection with Sources from the %s view',
async (view) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ function SourceSettingsForm({
}: SourceSettingsFormProps) {
const form = useConnectorSettingsForm({
connector: baseline,
syncing: isConnectorSyncingOrPending(connector),
scope,
knowledgeBaseId: connector.knowledgeBaseId,
isSearchIndex: true,
Expand All @@ -444,6 +445,7 @@ function SourceSettingsForm({
dirty: form.dirty,
saving: form.saving,
saveDisabled: !form.canSave,
saveTooltip: form.saveBlockedReason,
onSave: form.save,
onDiscard,
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ describe('connection method selection', () => {
isAvailabilityReady: false,
})
expect(container.textContent).not.toContain('This connection method is not available')
expect(container.querySelector('[aria-label="Sync using: Service account"]')).toBeDisabled()
expect(container.textContent).toContain('Service account')
expect(container.querySelector('[role="combobox"]')).toBeNull()
})

it('shows a real unavailable method after availability finishes loading', async () => {
Expand All @@ -119,15 +120,10 @@ describe('connection method selection', () => {
{ mode: 'admin', label: 'Service account' },
] as const)('shows a locked $mode method without allowing changes', async ({ mode, label }) => {
await render({ value: { accessMode: mode }, lockAccessMode: true })
const dropdown = container.querySelector<HTMLButtonElement>(
`[aria-label="Sync using: ${label}"]`
)
expect(dropdown).toBeDisabled()
expect(dropdown).toHaveTextContent(label)
expect(container.textContent).toContain('Add a new connection to change the sync method.')
expect(container.textContent).toContain(label)
expect(container.textContent).not.toContain('Add a new connection')
expect(container.querySelector('[role="radiogroup"]')).toBeNull()
await act(async () => dropdown!.click())
expect(document.querySelector('[role="menu"]')).toBeNull()
expect(container.querySelector('button')).toBeNull()
expect(onChange).not.toHaveBeenCalled()
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
ChipButtonGroup,
ChipButtonGroupItem,
ChipCombobox,
ChipDropdown,
ChipLink,
ChipModalField,
type ComboboxOption,
Expand Down Expand Up @@ -159,23 +158,13 @@ export function ConnectorAccessField({
hint={
canAdmin && isAvailabilityReady && !currentMode?.allowed
? `This connection method is not available in this ${scope.kind}.`
: lockAccessMode
? 'Add a new connection to change the sync method.'
: value.accessMode === 'workspace'
? 'Everyone in this workspace can search these documents.'
: undefined
: value.accessMode === 'workspace'
? 'Everyone in this workspace can search these documents.'
: undefined
}
>
<div className='flex flex-col gap-2'>
{slackSetupOnly ? null : lockAccessMode ? (
<ChipDropdown
aria-label={`Sync using: ${currentMode?.label ?? 'Unavailable'}`}
value={value.accessMode}
options={visibleModes.map(({ mode, label }) => ({ value: mode, label }))}
disabled
className='w-fit'
/>
) : showModeSelector ? (
{slackSetupOnly ? null : !lockAccessMode && showModeSelector ? (
<ChipButtonGroup
value={value.accessMode}
disabled={disabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ describe('connector settings service-account choices', () => {
it.each([true, false])(
'locks the sync method only for Search settings (%s)',
async (isSearchIndex) => {
await render(confluenceConnectorMeta, { isSearchIndex })
await render(confluenceConnectorMeta, { isSearchIndex, needsWorkspaceCredential: false })
expect(mocks.accessField).toHaveBeenLastCalledWith(
expect.objectContaining({ lockAccessMode: isSearchIndex })
)
Expand Down Expand Up @@ -501,6 +501,35 @@ describe('connector settings service-account choices', () => {
)
})

it('browses spaces with the draft replacement account without a separate save action', async () => {
mocks.renderConfigFields = true
mocks.credentials = [
{
id: 'replacement',
name: 'Updated account',
provider: 'confluence',
type: 'service_account',
},
]
await render(confluenceConnectorMeta, {
credentialId: 'previous',
workspaceCredentialId: 'replacement',
accessModeChanged: false,
sourceConfig: { domain: 'https://example.atlassian.net', spaceKey: ['ENG'] },
isFieldVisible: (field) => field.id === 'spaceSelector',
})

expect(mocks.selectorOptions).toHaveBeenLastCalledWith(
'confluence.spaces',
expect.objectContaining({
context: expect.objectContaining({ oauthCredential: 'replacement' }),
})
)
expect(mocks.accessField).not.toHaveBeenCalled()
expect(container.textContent).not.toContain('Change service account')
expect(container.textContent).not.toContain('Cancel')
})

it.each([
{
meta: confluenceConnectorMeta,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export interface ConnectorSettingsFieldsProps {
hasMaxAccess: boolean
isSaving: boolean
error: string | null
saveBlockedReason?: string
access: ConnectorAccessSelection
onAccessChange: (access: ConnectorAccessSelection) => void
canAdmin: boolean
Expand Down Expand Up @@ -133,6 +134,7 @@ export function ConnectorSettingsFields({
hasMaxAccess,
isSaving,
error,
saveBlockedReason,
access,
onAccessChange,
canAdmin,
Expand Down Expand Up @@ -204,7 +206,9 @@ export function ConnectorSettingsFields({
})
useCredentialRefreshTriggers(refetchCredentials, providerId ?? '', scope)
const [browseCredentialId, setBrowseCredentialId] = useState<string | null>(null)
const selectorCredentialId = syncsPerMember ? browseCredentialId : credentialId
const selectorCredentialId = syncsPerMember
? browseCredentialId
: (workspaceCredentialId ?? credentialId)
const selectorCredential = rawCredentials.find((item) => item.id === selectorCredentialId)
const installations = rawCredentials.filter(
(credential) => credential.provider === GITHUB_INSTALLATION_PROVIDER_ID
Expand All @@ -228,6 +232,7 @@ export function ConnectorSettingsFields({
[rawCredentials, connectorConfig, access.accessMode]
)

const hideFixedAccessMode = isSearchIndex && needsWorkspaceCredential && canAdmin && allowAdmin
const hiddenCapFieldIds = derivedAclCapFieldIds(connectorConfig, access.accessMode)
const isOptionalSetupField = (field: ConnectorConfigField) =>
Boolean(gitlabPermissions && connectorConfig) &&
Expand Down Expand Up @@ -330,70 +335,67 @@ export function ConnectorSettingsFields({
disabled={isSaving || !canAdmin}
/>
)}
{connectorConfig && showAccessField && !isGitHubInstallationSource && (
<ConnectorAccessField
scope={scope}
connectorConfig={connectorConfig}
value={access}
onChange={onAccessChange}
canAdmin={canAdmin}
lockAccessMode={isSearchIndex}
isAvailabilityReady={availability.isReady}
allowMembers={allowMembers}
allowAdmin={allowAdmin}
allowWorkspace={allowWorkspace}
disabled={isSaving}
footer={
canReenableMemberSync ? (
<div className='flex flex-col gap-2'>
<div>
<Chip
variant='primary'
onClick={onApplyAccess}
disabled={!accessComplete || isSaving}
>
{isSwitchingAccess ? 'Re-enabling…' : 'Re-enable per-member sync'}
</Chip>
</div>
<p className='text-[var(--text-muted)] text-caption leading-snug'>
Members and their documents are kept; the next sync restores their access.
</p>
</div>
) : accessDirty ? (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<Chip
variant='primary'
onClick={onApplyAccess}
disabled={!accessComplete || isSaving}
>
{isSwitchingAccess
? 'Switching…'
: isContentCredentialChange
? isSearchIndex
? requiresServiceAccount
? 'Change service account'
: 'Change account'
: 'Change indexing account'
: 'Apply connection method'}
</Chip>
<Chip onClick={onResetAccess} disabled={isSaving}>
{accessSetupHint ? 'Edit settings' : 'Cancel'}
</Chip>
{connectorConfig &&
showAccessField &&
!isGitHubInstallationSource &&
!hideFixedAccessMode && (
<ConnectorAccessField
scope={scope}
connectorConfig={connectorConfig}
value={access}
onChange={onAccessChange}
canAdmin={canAdmin}
lockAccessMode={isSearchIndex}
isAvailabilityReady={availability.isReady}
allowMembers={allowMembers}
allowAdmin={allowAdmin}
allowWorkspace={allowWorkspace}
disabled={isSaving}
footer={
canReenableMemberSync ? (
<div className='flex flex-col gap-2'>
<div>
<Chip
variant='primary'
onClick={onApplyAccess}
disabled={!accessComplete || isSaving}
>
{isSwitchingAccess ? 'Re-enabling…' : 'Re-enable per-member sync'}
</Chip>
</div>
<p className='text-[var(--text-muted)] text-caption leading-snug'>
Members and their documents are kept; the next sync restores their access.
</p>
</div>
<p className='text-[var(--text-muted)] text-caption leading-snug'>
{accessSetupHint ??
(isContentCredentialChange
? syncsPerMember
) : accessDirty && (!isContentCredentialChange || syncsPerMember) ? (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<Chip
variant='primary'
onClick={onApplyAccess}
disabled={!accessComplete || isSaving}
>
{isSwitchingAccess
? 'Switching…'
: isContentCredentialChange
? 'Change indexing account'
: 'Apply connection method'}
</Chip>
<Chip onClick={onResetAccess} disabled={isSaving}>
{accessSetupHint ? 'Edit settings' : 'Cancel'}
</Chip>
</div>
<p className='text-[var(--text-muted)] text-caption leading-snug'>
{accessSetupHint ??
(isContentCredentialChange
? 'The next sync uses this account. Members keep their connected accounts and source permissions.'
: 'The next sync uses this account and refreshes source permissions.'
: SWITCH_NOTICE[access.accessMode])}
</p>
</div>
) : undefined
}
/>
)}
: SWITCH_NOTICE[access.accessMode])}
</p>
</div>
) : undefined
}
/>
)}

{connectorConfig && needsWorkspaceCredential && canAdmin && (
<ChipModalField
Expand Down Expand Up @@ -551,6 +553,11 @@ export function ConnectorSettingsFields({
</ChipModalField>
)}

{saveBlockedReason && (
<p role='status' className='px-2 text-[var(--text-muted)] text-caption'>
{saveBlockedReason}
</p>
)}
<ChipModalError>{error}</ChipModalError>
</>
)
Expand Down
Loading
Loading