mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(settings): keep integration connect dialog drafts on backdrop click (#20932)
* fix(settings): keep integration connect dialog drafts on backdrop click A backdrop click in the Settings → Integrations Jira/Linear/Bitbucket connect dialogs dismissed the Radix modal, and each dialog's reset-on-open then wiped the typed credential. Generalize SshTargetForm's dirty-gated outside dismissal into a shared preventOutsideDismissWhenDirty factory and wire it into the three dialogs (and SshTargetForm), so an accidental backdrop click no longer discards a draft while Escape / Cancel / × remain the explicit discard paths. Bitbucket compares email/baseUrl against a props-seeded baseline and only counts the active auth mode's fields, so a mid-edit status refresh and a mode toggle cannot make the form sticky. STA-7332 * test(e2e): drop ticket id from dismiss spec comment
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { JiraConnectDialog } from './jira-connect-dialog'
|
||||
import { LinearApiKeyDialog } from './linear-api-key-dialog'
|
||||
import { BitbucketCredentialsDialog } from './settings/bitbucket-credentials-dialog'
|
||||
|
||||
type StoreState = {
|
||||
settings: { activeRuntimeEnvironmentId: string | null }
|
||||
connectJira: (input: unknown) => Promise<{ ok: boolean; error?: string }>
|
||||
connectLinear: (apiKey: string) => Promise<{ ok: boolean; error?: string }>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const store: { current: StoreState | null } = { current: null }
|
||||
return { store }
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: StoreState) => unknown) => {
|
||||
if (!mocks.store.current) {
|
||||
throw new Error('Store state was not installed')
|
||||
}
|
||||
return selector(mocks.store.current)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
getActiveRuntimeTarget: () => ({ kind: 'local' })
|
||||
}))
|
||||
|
||||
let root: Root | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.store.current = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
connectJira: vi.fn(async () => ({ ok: true })),
|
||||
connectLinear: vi.fn(async () => ({ ok: true }))
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
}
|
||||
root = null
|
||||
document.body.innerHTML = ''
|
||||
mocks.store.current = null
|
||||
})
|
||||
|
||||
async function renderDialog(ui: ReactElement, existingRoot?: Root): Promise<Root> {
|
||||
const targetRoot = existingRoot ?? createRoot(appendContainer())
|
||||
root = targetRoot
|
||||
await act(async () => {
|
||||
targetRoot.render(ui)
|
||||
})
|
||||
// Why: Radix attaches its document pointerdown listener on a setTimeout(0), so a
|
||||
// synchronous dispatch right after mount is missed.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
return targetRoot
|
||||
}
|
||||
|
||||
function appendContainer(): HTMLDivElement {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
return container
|
||||
}
|
||||
|
||||
async function outsideClick(): Promise<void> {
|
||||
await act(async () => {
|
||||
// Why: modal DialogContent sets deferPointerDownOutside, so the dismissal resolves on the
|
||||
// click that follows the outside pointerdown. Events must bubble to reach the document listeners.
|
||||
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
}
|
||||
|
||||
async function pressEscape(): Promise<void> {
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
async function click(element: HTMLElement): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
}
|
||||
|
||||
async function type(input: HTMLInputElement, value: string): Promise<void> {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
function inputByPlaceholder(placeholder: string): HTMLInputElement {
|
||||
const input = document.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)
|
||||
if (!input) {
|
||||
throw new Error(`missing input with placeholder ${placeholder}`)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function buttonByText(label: string): HTMLButtonElement {
|
||||
const match = Array.from(document.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === label
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(`missing ${label} button`)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
describe('JiraConnectDialog outside dismiss', () => {
|
||||
it('keeps a typed site URL when the backdrop is clicked', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<JiraConnectDialog open onOpenChange={onOpenChange} />)
|
||||
const siteUrl = inputByPlaceholder('https://example.atlassian.net')
|
||||
|
||||
await type(siteUrl, 'https://acme.atlassian.net')
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
expect(siteUrl.value).toBe('https://acme.atlassian.net')
|
||||
})
|
||||
|
||||
it('keeps a typed email and API token when the backdrop is clicked', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<JiraConnectDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await type(inputByPlaceholder('you@example.com'), 'dev@example.com')
|
||||
await outsideClick()
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
|
||||
await type(inputByPlaceholder('Atlassian API token'), 'jira-token')
|
||||
await outsideClick()
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still dismisses on a backdrop click while the form is clean', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<JiraConnectDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
// Why: mode switches clear the credential fields, so a toggle alone leaves nothing to lose.
|
||||
it('still dismisses on a backdrop click after a mode toggle with nothing typed', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<JiraConnectDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await click(buttonByText('Self-hosted'))
|
||||
expect(inputByPlaceholder('https://jira.example.com')).not.toBeNull()
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('still discards a typed draft on Escape', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<JiraConnectDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await type(inputByPlaceholder('https://example.atlassian.net'), 'https://acme.atlassian.net')
|
||||
await pressEscape()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LinearApiKeyDialog outside dismiss', () => {
|
||||
it('keeps a typed key when the backdrop is clicked', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<LinearApiKeyDialog open onOpenChange={onOpenChange} />)
|
||||
const key = inputByPlaceholder('lin_api_...')
|
||||
|
||||
await type(key, 'lin_api_secret')
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
expect(key.value).toBe('lin_api_secret')
|
||||
})
|
||||
|
||||
it('still dismisses on a backdrop click while the key is empty', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<LinearApiKeyDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('still discards a typed key on Escape', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<LinearApiKeyDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await type(inputByPlaceholder('lin_api_...'), 'lin_api_secret')
|
||||
await pressEscape()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BitbucketCredentialsDialog outside dismiss', () => {
|
||||
it('keeps a typed email when the backdrop is clicked', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
|
||||
const email = inputByPlaceholder('you@example.com')
|
||||
|
||||
await type(email, 'dev@example.com')
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
expect(email.value).toBe('dev@example.com')
|
||||
})
|
||||
|
||||
it('keeps a typed API token and base URL when the backdrop is clicked', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await type(inputByPlaceholder('Atlassian API token'), 'bb-token')
|
||||
await outsideClick()
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
|
||||
await type(inputByPlaceholder('https://api.bitbucket.org/2.0'), 'https://api.internal/2.0')
|
||||
await outsideClick()
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a typed access token in token mode when the backdrop is clicked', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await click(buttonByText('Access token'))
|
||||
await type(inputByPlaceholder('Repository, project, or workspace access token'), 'bb-access')
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: the base URL is submitted in both auth modes, so it must block dismissal in token mode too.
|
||||
it('keeps a typed base URL in token mode when the backdrop is clicked', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await click(buttonByText('Access token'))
|
||||
await type(inputByPlaceholder('https://api.bitbucket.org/2.0'), 'https://api.internal/2.0')
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still dismisses on a backdrop click while the form is clean', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
// Why: the baseline is seeded from the initial props, so an untouched prefilled edit form is
|
||||
// clean and must still dismiss on a backdrop click.
|
||||
it('still dismisses on a backdrop click for an untouched prefilled edit', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(
|
||||
<BitbucketCredentialsDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
initialAuthMode="basic"
|
||||
initialEmail="prefilled@example.com"
|
||||
initialBaseUrl="https://api.example.com"
|
||||
/>
|
||||
)
|
||||
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
// Why: a status refresh may rewrite the stored metadata mid-edit; the baseline must stay at the
|
||||
// values captured at open so an untouched form does not become sticky.
|
||||
it('stays clean when the stored metadata is refreshed mid-edit', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const targetRoot = await renderDialog(
|
||||
<BitbucketCredentialsDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
initialEmail="first@example.com"
|
||||
/>
|
||||
)
|
||||
|
||||
await renderDialog(
|
||||
<BitbucketCredentialsDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
initialEmail="refreshed@example.com"
|
||||
initialBaseUrl="https://api.example.com"
|
||||
/>,
|
||||
targetRoot
|
||||
)
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
expect(inputByPlaceholder('you@example.com').value).toBe('first@example.com')
|
||||
})
|
||||
|
||||
it('keeps a typed draft when the stored metadata is refreshed mid-edit', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const targetRoot = await renderDialog(
|
||||
<BitbucketCredentialsDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
initialEmail="first@example.com"
|
||||
/>
|
||||
)
|
||||
const email = inputByPlaceholder('you@example.com')
|
||||
await type(email, 'typed@example.com')
|
||||
|
||||
await renderDialog(
|
||||
<BitbucketCredentialsDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
initialEmail="refreshed@example.com"
|
||||
/>,
|
||||
targetRoot
|
||||
)
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
expect(email.value).toBe('typed@example.com')
|
||||
})
|
||||
|
||||
// Why: mode switches clear the secret fields, so a toggle alone leaves nothing to lose.
|
||||
it('still dismisses on a backdrop click after a mode toggle with nothing typed', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await click(buttonByText('Access token'))
|
||||
expect(inputByPlaceholder('Repository, project, or workspace access token')).not.toBeNull()
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
// Why: a basic-mode email is not submitted in token mode, so it must not make the token form
|
||||
// sticky — only the fields the active mode submits count as the draft.
|
||||
it('dismisses after switching to token mode with only a basic-mode email typed', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await type(inputByPlaceholder('you@example.com'), 'dev@example.com')
|
||||
await click(buttonByText('Access token'))
|
||||
expect(inputByPlaceholder('Repository, project, or workspace access token')).not.toBeNull()
|
||||
await outsideClick()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('still discards a typed draft on Escape', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
await renderDialog(<BitbucketCredentialsDialog open onOpenChange={onOpenChange} />)
|
||||
|
||||
await type(inputByPlaceholder('you@example.com'), 'dev@example.com')
|
||||
await pressEscape()
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context'
|
||||
import { preventOutsideDismissWhenDirty } from '@/lib/outside-dismiss-guard'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type JiraConnectDialogProps = {
|
||||
@@ -112,6 +113,11 @@ export function JiraConnectDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a stray backdrop click must not discard typed credentials. Mode switches clear the
|
||||
// credential fields, so a toggle alone is not dirty. Escape / Cancel / × stay explicit.
|
||||
const isDraftDirty = (): boolean => siteUrl !== '' || email !== '' || apiToken !== ''
|
||||
const guardOutsideDismiss = preventOutsideDismissWhenDirty(isDraftDirty)
|
||||
|
||||
const handleConnect = async (): Promise<void> => {
|
||||
const trimmedSite = siteUrl.trim()
|
||||
const trimmedEmail = email.trim()
|
||||
@@ -164,6 +170,8 @@ export function JiraConnectDialog({
|
||||
<DialogContent
|
||||
overlayClassName={overlayClassName}
|
||||
className={cn('sm:max-w-md', contentClassName)}
|
||||
onPointerDownOutside={guardOutsideDismiss}
|
||||
onInteractOutside={guardOutsideDismiss}
|
||||
>
|
||||
<DialogHeader className="gap-3">
|
||||
<DialogTitle className="leading-tight">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { preventOutsideDismissWhenDirty } from '@/lib/outside-dismiss-guard'
|
||||
import {
|
||||
createLinearApiKeyDialogState,
|
||||
resolveLinearApiKeyDialogState
|
||||
@@ -74,6 +75,10 @@ export function LinearApiKeyDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a stray backdrop click must not discard a typed API key. Escape / Cancel / × stay explicit.
|
||||
const isDraftDirty = (): boolean => apiKeyDraft !== ''
|
||||
const guardOutsideDismiss = preventOutsideDismissWhenDirty(isDraftDirty)
|
||||
|
||||
const handleConnect = async (): Promise<void> => {
|
||||
const apiKey = apiKeyDraft.trim()
|
||||
if (!apiKey || connectState === 'connecting') {
|
||||
@@ -125,6 +130,8 @@ export function LinearApiKeyDialog({
|
||||
<DialogContent
|
||||
overlayClassName={overlayClassName}
|
||||
className={cn('sm:max-w-lg', contentClassName)}
|
||||
onPointerDownOutside={guardOutsideDismiss}
|
||||
onInteractOutside={guardOutsideDismiss}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && apiKeyDraft.trim() && connectState !== 'connecting') {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -184,6 +184,34 @@ describe('SshTargetForm', () => {
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('blocks a backdrop dismissal while the draft differs from the baseline', async () => {
|
||||
const editTarget: EditingTarget = { ...EMPTY_FORM, label: 'dev-box', host: 'dev-box.lan' }
|
||||
const onOpenChange = vi.fn()
|
||||
const root = await renderForm({ open: false, onOpenChange })
|
||||
await renderForm({ open: true, editingId: 'target-1', form: editTarget, onOpenChange }, root)
|
||||
await renderForm(
|
||||
{
|
||||
open: true,
|
||||
editingId: 'target-1',
|
||||
form: { ...editTarget, host: 'other.lan' },
|
||||
onOpenChange
|
||||
},
|
||||
root
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true, cancelable: true })
|
||||
)
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('opens Advanced by default when the target already has advanced values', async () => {
|
||||
const root = await renderForm({
|
||||
editingId: 'target-1',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type EditingTarget
|
||||
} from './ssh-target-draft'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { preventOutsideDismissWhenDirty } from '@/lib/outside-dismiss-guard'
|
||||
export { EMPTY_FORM, type EditingTarget } from './ssh-target-draft'
|
||||
|
||||
type SshTargetFormProps = {
|
||||
@@ -90,21 +91,18 @@ export function SshTargetForm({
|
||||
isEditing &&
|
||||
(editingLabel !== '' || (endpointSummary !== '' && endpointSummary !== editingLabel))
|
||||
|
||||
const preventOutsideDismiss = (event: Event): void => {
|
||||
// Why: outside click is easy to hit by accident with a long multi-field form;
|
||||
// keep Escape / Cancel / × as explicit discard paths. Read both refs at call
|
||||
// time — the session effect can rewrite the baseline without a re-render.
|
||||
if (isSshTargetFormDirty(formRef.current, baselineRef.current)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
// Why: outside click is easy to hit by accident with a long multi-field form; keep Escape /
|
||||
// Cancel / × as explicit discard paths. Read both refs at call time — the session effect can
|
||||
// rewrite the baseline without a re-render.
|
||||
const isDraftDirty = (): boolean => isSshTargetFormDirty(formRef.current, baselineRef.current)
|
||||
const guardOutsideDismiss = preventOutsideDismissWhenDirty(isDraftDirty)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100vh-3rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
|
||||
onPointerDownOutside={preventOutsideDismiss}
|
||||
onInteractOutside={preventOutsideDismiss}
|
||||
onPointerDownOutside={guardOutsideDismiss}
|
||||
onInteractOutside={guardOutsideDismiss}
|
||||
>
|
||||
<form
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, useLayoutEffect, useState } from 'react'
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { ExternalLink, LoaderCircle, Lock } from 'lucide-react'
|
||||
import type { BitbucketAuthMode } from '../../../../shared/bitbucket-credentials'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -17,6 +17,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context'
|
||||
import { preventOutsideDismissWhenDirty } from '@/lib/outside-dismiss-guard'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const API_TOKEN_DOCS_URL = 'https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/'
|
||||
@@ -63,6 +64,9 @@ export function BitbucketCredentialsDialog({
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [connectState, setConnectState] = useState<ConnectState>('idle')
|
||||
const [connectError, setConnectError] = useState<string | null>(null)
|
||||
// Why: the form is seeded from `initial*` props that a status refresh may rewrite mid-edit, so
|
||||
// compare text fields against the values captured at open rather than the live props.
|
||||
const baselineRef = useRef({ email: '', baseUrl: '' })
|
||||
|
||||
// Re-sync from the latest stored metadata on every open, not just on mount, so
|
||||
// the Edit flow never shows values from a previous connection. Secrets always
|
||||
@@ -71,9 +75,12 @@ export function BitbucketCredentialsDialog({
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const seedEmail = initialEmail ?? ''
|
||||
const seedBaseUrl = initialBaseUrl ?? ''
|
||||
baselineRef.current = { email: seedEmail, baseUrl: seedBaseUrl }
|
||||
setAuthMode(initialAuthMode ?? 'basic')
|
||||
setEmail(initialEmail ?? '')
|
||||
setBaseUrl(initialBaseUrl ?? '')
|
||||
setEmail(seedEmail)
|
||||
setBaseUrl(seedBaseUrl)
|
||||
setApiToken('')
|
||||
setAccessToken('')
|
||||
setConnectState('idle')
|
||||
@@ -106,6 +113,14 @@ export function BitbucketCredentialsDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a stray backdrop click must not discard typed credentials. Only the active mode's fields
|
||||
// are submitted, so compare just those — a stale basic-mode email is not submitted in token mode
|
||||
// and must not make the form sticky. Escape / Cancel / × stay explicit.
|
||||
const isDraftDirty = (): boolean =>
|
||||
baseUrl !== baselineRef.current.baseUrl ||
|
||||
(isTokenMode ? accessToken !== '' : email !== baselineRef.current.email || apiToken !== '')
|
||||
const guardOutsideDismiss = preventOutsideDismissWhenDirty(isDraftDirty)
|
||||
|
||||
const handleConnect = async (): Promise<void> => {
|
||||
if (!canSubmit) {
|
||||
return
|
||||
@@ -153,6 +168,8 @@ export function BitbucketCredentialsDialog({
|
||||
<DialogContent
|
||||
overlayClassName={overlayClassName}
|
||||
className={cn('sm:max-w-lg', contentClassName)}
|
||||
onPointerDownOutside={guardOutsideDismiss}
|
||||
onInteractOutside={guardOutsideDismiss}
|
||||
onKeyDown={(event) => {
|
||||
// Only from a text field: Enter on Cancel or the docs link must do
|
||||
// what that control does, not submit the form.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { preventOutsideDismissWhenDirty } from './outside-dismiss-guard'
|
||||
|
||||
describe('preventOutsideDismissWhenDirty', () => {
|
||||
it('prevents the outside dismiss while the draft is dirty', () => {
|
||||
const event = { preventDefault: vi.fn() }
|
||||
|
||||
preventOutsideDismissWhenDirty(() => true)(event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows the outside dismiss while the draft is clean', () => {
|
||||
const event = { preventDefault: vi.fn() }
|
||||
|
||||
preventOutsideDismissWhenDirty(() => false)(event)
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the predicate at event time, not when the handler is created', () => {
|
||||
let dirty = false
|
||||
const guard = preventOutsideDismissWhenDirty(() => dirty)
|
||||
const event = { preventDefault: vi.fn() }
|
||||
|
||||
guard(event)
|
||||
dirty = true
|
||||
guard(event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// Why a structural event shape and not Radix's event type: this module stays dependency-free and
|
||||
// the handler only needs `preventDefault`, which every outside-dismiss event provides.
|
||||
type PreventableOutsideEvent = { preventDefault: () => void }
|
||||
|
||||
/**
|
||||
* Block Radix outside-dismiss while `isDirty()` is true, so an accidental backdrop click cannot
|
||||
* discard a draft. Escape / Cancel / × stay the explicit discard paths.
|
||||
*
|
||||
* Why a predicate instead of a boolean: callers that mutate their dirty baseline in an effect
|
||||
* need the check evaluated at event time, not captured at render. Do not memoize the returned
|
||||
* handler — it must be recreated each render so Radix reads the latest predicate.
|
||||
*/
|
||||
export function preventOutsideDismissWhenDirty(
|
||||
isDirty: () => boolean
|
||||
): (event: PreventableOutsideEvent) => void {
|
||||
return (event) => {
|
||||
if (isDirty()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* A backdrop click in the Settings → Integrations Linear and Jira connect dialogs must
|
||||
* not close the dialog and discard typed credentials. Escape / Cancel stay the explicit discard
|
||||
* paths. (Bitbucket's baseline-seeded predicate is covered by the component tests.)
|
||||
*
|
||||
* Mirrors the SSH host form modal guard (tests/e2e/ssh-host-form-modal.spec.ts).
|
||||
*/
|
||||
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { dismissTransientAnnouncement } from './helpers/ssh-config-host-picker'
|
||||
import { waitForSessionReady } from './helpers/store'
|
||||
|
||||
async function openIntegrationsSettings(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
// Why: the spec asserts on English strings; the host may run a non-English locale.
|
||||
await store.getState().updateSettings({ uiLanguage: 'en' })
|
||||
store.getState().openSettingsTarget({ pane: 'integrations', repoId: null })
|
||||
store.getState().openSettingsPage()
|
||||
})
|
||||
await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 })
|
||||
await dismissTransientAnnouncement(page)
|
||||
}
|
||||
|
||||
async function clickBackdrop(page: Page): Promise<void> {
|
||||
// Why: the overlay is fixed inset-0 and the dialog sits over its center, so click near a corner.
|
||||
await page.locator('[data-slot="dialog-overlay"]').click({ position: { x: 8, y: 8 } })
|
||||
}
|
||||
|
||||
test.describe('Settings integrations connect dialogs', () => {
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await openIntegrationsSettings(orcaPage)
|
||||
})
|
||||
|
||||
test('Linear API key draft survives a backdrop click but clears on cancel', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const card = orcaPage.locator('[data-settings-section="integrations-linear"]')
|
||||
// Why: the button label depends on connection state; a fresh profile is disconnected.
|
||||
const openButton = card.getByRole('button', {
|
||||
name: /^(Add Linear access|Add workspace access)$/
|
||||
})
|
||||
await expect(openButton).toBeVisible({ timeout: 15_000 })
|
||||
await openButton.click()
|
||||
|
||||
const dialog = orcaPage.getByRole('dialog', { name: 'Add Linear access' })
|
||||
await expect(dialog).toBeVisible()
|
||||
const keyInput = dialog.locator('input[type="password"]')
|
||||
await keyInput.fill('lin_api_e2e_secret')
|
||||
|
||||
await clickBackdrop(orcaPage)
|
||||
// Why: assert the settled open state, not the exit-animation frame a broken guard would leave.
|
||||
await expect(dialog).toHaveAttribute('data-state', 'open')
|
||||
await expect(keyInput).toHaveValue('lin_api_e2e_secret')
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// Explicit cancel discards; reopening starts empty.
|
||||
await openButton.click()
|
||||
await expect(dialog.locator('input[type="password"]')).toHaveValue('')
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('Jira site URL draft survives a backdrop click', async ({ orcaPage }) => {
|
||||
const card = orcaPage.locator('[data-settings-section="integrations-jira"]')
|
||||
// Why: the button label depends on connection state; a fresh profile is disconnected.
|
||||
const openButton = card.getByRole('button', { name: /^(Connect Jira|Add Jira site)$/ })
|
||||
await expect(openButton).toBeVisible({ timeout: 15_000 })
|
||||
await openButton.click()
|
||||
|
||||
const dialog = orcaPage.getByRole('dialog', { name: 'Connect Jira site' })
|
||||
await expect(dialog).toBeVisible()
|
||||
const siteUrlInput = dialog.locator('input[placeholder="https://example.atlassian.net"]')
|
||||
await siteUrlInput.fill('https://acme.atlassian.net')
|
||||
|
||||
await clickBackdrop(orcaPage)
|
||||
// Why: assert the settled open state, not the exit-animation frame a broken guard would leave.
|
||||
await expect(dialog).toHaveAttribute('data-state', 'open')
|
||||
await expect(siteUrlInput).toHaveValue('https://acme.atlassian.net')
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user