Files
Danny White e7796315d2 fix sheets stealing focus (#48521)
## What kind of change does this PR introduce?

Bug fix. Resolves DEPR-539.

## What is the current behavior?

When a focused child unmounts, Radix can move focus to the Sheet wrapper
and break the expected tab order. Several callsites suppress the
wrapper's tabindex individually.

## What is the new behavior?

Sheet still focuses its first interactive child when opened, but the
wrapper itself is no longer focusable by default. Callers can opt in
with an explicit `tabIndex` when needed.

## Additional context

### Testing

Compare this Studio experience on both this branch and `master`:

1. Open any project with an Edge Function.
2. Go to **Edge Functions**, open the function, then click **Test**.
3. Under **Headers**, click **Add Headers**. Click the first header key
input, then Tab slowly through the header inputs and remove buttons.

On `master`, focus can jump to the whole Sheet. On this branch, focus
stays on the controls in order.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved keyboard focus behavior across sheets and panels.
* Sheets now focus the first available interactive element when opened,
without adding unnecessary focus targets.
* Preserved support for programmatic focus and prevented focus from
unexpectedly moving to the sheet when focused content is removed.
* Updated authentication, integrations, connection, logging, storage,
and other sheet interfaces consistently.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 10:01:47 +10:00

414 lines
15 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Form,
FormControl,
FormField,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { IntegrationOverviewTab } from '../../Integration/IntegrationOverviewTab'
import { RequiredExtensionsSection } from '../../Integration/RequiredExtensionsSection'
import { InstallationError } from './InstallationError'
import { IntegrationInstalledActions, IntegrationNotInstalledActions } from './IntegrationActions'
import {
canInstall as checkCanInstall,
hasInstallError,
hasUninstallError,
isInstallDone,
isInstalled,
isInstalling,
isUninstallDone,
isUninstalling,
} from './stripe-sync-status'
import { StripeSyncChangesCard } from './StripeSyncChangesCard'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus'
import { useStripeSyncInstallMutation } from '@/data/database-integrations/stripe/stripe-sync-install-mutation'
import { useStripeSyncUninstallMutation } from '@/data/database-integrations/stripe/stripe-sync-uninstall-mutation'
import { useSchemasQuery } from '@/data/database/schemas-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useTrack } from '@/lib/telemetry/track'
const installFormSchema = z.object({
stripeSecretKey: z.string().min(1, 'Stripe API key is required'),
})
const StripeSyncContent = ({ hideInstallCTA = false }: { hideInstallCTA?: boolean }) => {
const track = useTrack()
const hasTrackedInstallFailed = useRef(false)
const { data: project } = useSelectedProjectQuery()
const [showUninstallModal, setShowUninstallModal] = useState(false)
const [shouldShowInstallSheet, setShouldShowInstallSheet] = useState(false)
// These flags bridge the gap between mutation success and schema update
const [isInstallInitiated, setIsInstallInitiated] = useState(false)
const [isUninstallInitiated, setIsUninstallInitiated] = useState(false)
const formId = 'stripe-sync-install-form'
const form = useForm<z.infer<typeof installFormSchema>>({
resolver: zodResolver(installFormSchema),
defaultValues: { stripeSecretKey: '' },
mode: 'onSubmit',
})
const {
schemaComment,
schemaComment: { status: installationStatus },
latestAvailableVersion,
timedOut,
} = useStripeSyncStatus()
// Check permissions for managing function secrets
const { can: canManageSecrets } = useAsyncCheckPermissions(
PermissionAction.FUNCTIONS_SECRET_WRITE,
'*'
)
const installed = isInstalled(installationStatus)
const installError = hasInstallError(installationStatus)
const uninstallError = hasUninstallError(installationStatus)
const installInProgress = isInstalling(installationStatus)
const uninstallInProgress = isUninstalling(installationStatus)
const installDone = isInstallDone(installationStatus)
const uninstallDone = isUninstallDone(installationStatus)
// Detect if this is an upgrade (both old and new versions present)
let oldVersion
let newVersion
if (installed) {
// when installed we compare the installed version against the latest available
oldVersion = schemaComment?.newVersion
newVersion = latestAvailableVersion
} else {
// otherwise compare the old and new versions from the schema
oldVersion = schemaComment?.oldVersion
newVersion = schemaComment?.newVersion
}
const upgradeAvailable = !!(oldVersion && newVersion && oldVersion !== newVersion)
const upgradeDone = latestAvailableVersion == schemaComment?.newVersion
const {
mutate: installStripeSync,
isPending: isInstallRequested,
error: installRequestError,
reset: resetInstallError,
} = useStripeSyncInstallMutation({
onSuccess: () => {
toast.success(
upgradeAvailable ? 'Stripe Sync upgrade started' : 'Stripe Sync installation started'
)
setShouldShowInstallSheet(false)
form.reset()
setIsInstallInitiated(true)
},
})
const { mutate: uninstallStripeSync, isPending: isUninstallRequested } =
useStripeSyncUninstallMutation({
onSuccess: () => {
toast.success('Stripe Sync uninstallation started')
setShowUninstallModal(false)
setIsUninstallInitiated(true)
},
})
// Combine schema status with mutation/initiated states for UI
const installing = (installInProgress || isInstallRequested || isInstallInitiated) && !timedOut
const uninstalling =
(uninstallInProgress || isUninstallRequested || isUninstallInitiated) && !timedOut
const canInstall = checkCanInstall(installationStatus) && !installed && !installing
const hasError = (uninstallError || installError) && ((!uninstalling && !installing) || timedOut)
// Poll for schema changes during transitions
useSchemasQuery(
{ projectRef: project?.ref, connectionString: project?.connectionString },
{ refetchInterval: installing || uninstalling ? 5000 : false }
)
const handleUninstall = useCallback(() => {
if (!project?.ref) return
uninstallStripeSync({
projectRef: project.ref,
startTime: Date.now(),
})
}, [project?.ref, uninstallStripeSync])
const handleOpenInstallSheet = useCallback(() => {
resetInstallError()
setShouldShowInstallSheet(true)
}, [resetInstallError])
const handleCloseInstallSheet = (isOpen: boolean) => {
if (isInstallRequested) return
setShouldShowInstallSheet(isOpen)
if (!isOpen) {
form.reset()
resetInstallError()
}
}
// Track install failures
useEffect(() => {
if (!installError) {
hasTrackedInstallFailed.current = false
return
}
if (!hasTrackedInstallFailed.current) {
hasTrackedInstallFailed.current = true
track('integration_install_failed', {
integrationName: 'stripe_sync_engine',
})
}
}, [installError, track])
// Clear install initiated flag once schema reflects successful completion
// For errors, the flag is cleared when user manually retries (handleOpenInstallSheet)
useEffect(() => {
if (isInstallInitiated && installDone && upgradeDone && !installError) {
setIsInstallInitiated(false)
}
}, [isInstallInitiated, installDone, upgradeDone, installError])
// Clear uninstall initiated flag once schema is removed or error
useEffect(() => {
if (isUninstallInitiated && uninstallDone) {
setIsUninstallInitiated(false)
}
}, [isUninstallInitiated, uninstallDone])
return (
<>
{hasError && (
<InstallationError
error={uninstallError ? 'uninstall' : 'install'}
handleUninstall={handleUninstall}
handleOpenInstallSheet={handleOpenInstallSheet}
isUpgrade={upgradeAvailable}
installing={installing}
uninstalling={uninstalling}
/>
)}
{!installed && !uninstalling && !uninstallError ? (
<>
<StripeSyncChangesCard
installationStatus={installationStatus}
isUpgrade={upgradeAvailable}
/>
<IntegrationNotInstalledActions
hideInstallCTA={hideInstallCTA}
installing={installing}
canInstall={canInstall}
isUninstallRequested={isUninstallRequested}
handleUninstall={handleUninstall}
setShouldShowInstallSheet={setShouldShowInstallSheet}
/>
</>
) : (
(installed || uninstalling || uninstallError) && (
<>
<StripeSyncChangesCard
installationStatus={installationStatus}
isUpgrade={upgradeAvailable}
/>
<IntegrationInstalledActions
disabled={installing || uninstalling || !canManageSecrets}
upgradeAvailable={upgradeAvailable}
installing={installing}
uninstalling={uninstalling}
isUninstallRequested={isUninstallRequested}
setShouldShowInstallSheet={setShouldShowInstallSheet}
setShowUninstallModal={setShowUninstallModal}
/>
</>
)
)}
<Sheet open={!!shouldShowInstallSheet} onOpenChange={handleCloseInstallSheet}>
<SheetContent size="lg" className="flex flex-col gap-0">
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(({ stripeSecretKey }) => {
if (!project?.ref) return
installStripeSync({
projectRef: project.ref,
stripeSecretKey,
startTime: Date.now(),
})
})}
className="overflow-auto grow px-0 flex flex-col"
>
<SheetHeader>
<SheetTitle>
{upgradeAvailable ? 'Upgrade' : 'Install'} Stripe Sync Engine
</SheetTitle>
</SheetHeader>
<SheetSection className="flex-1 flex flex-col gap-y-6">
<StripeSyncChangesCard
installationStatus={installationStatus}
isUpgrade={upgradeAvailable}
/>
<h3 className="heading-default">Configuration</h3>
<div className="flex flex-col gap-y-2">
<FormField
control={form.control}
name="stripeSecretKey"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Stripe API secret key"
description="Your Stripe secret key. Requires write access to Webhook Endpoints and read-only access to all other categories."
>
<FormControl className="col-span-8">
<Input
id="stripe_api_key"
name="stripe_api_key"
placeholder="Enter your Stripe API key"
autoComplete="stripe-api-key"
reveal={false}
disabled={isInstallRequested}
type="password"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
</FormItemLayout>
)}
/>
<div className="flex items-center gap-x-2">
<Button asChild variant="default" icon={<ExternalLink />}>
<Link
target="_blank"
rel="noopener noreferrer"
href="https://dashboard.stripe.com/apikeys"
>
Get Stripe API key
</Link>
</Button>
<Button asChild variant="default" icon={<ExternalLink />}>
<Link
target="_blank"
rel="noopener noreferrer"
href="https://support.stripe.com/questions/what-are-stripe-api-keys-and-how-to-find-them"
>
What are Stripe API keys?
</Link>
</Button>
</div>
</div>
{installRequestError && (
<Admonition
type="destructive"
title="Installation failed"
description={installRequestError.message}
/>
)}
</SheetSection>
<SheetFooter>
<Button
variant="default"
disabled={isInstallRequested}
onClick={() => handleCloseInstallSheet(false)}
>
Cancel
</Button>
<Button
form={formId}
type="submit"
variant="primary"
loading={isInstallRequested}
disabled={!form.formState.isValid || isInstallRequested}
>
{isInstallRequested
? upgradeAvailable
? 'Upgrading'
: 'Installing'
: upgradeAvailable
? 'Upgrade integration'
: 'Install integration'}
</Button>
</SheetFooter>
</form>
</Form>
</SheetContent>
</Sheet>
<ConfirmationModal
visible={showUninstallModal}
title="Uninstall Stripe Sync Engine"
confirmLabel="Uninstall"
confirmLabelLoading="Uninstalling..."
variant="destructive"
loading={isUninstallRequested}
onCancel={() => setShowUninstallModal(false)}
onConfirm={handleUninstall}
>
<p className="text-sm text-foreground-light">
Are you sure you want to uninstall the Stripe Sync Engine? This will:
</p>
<ul className="list-disc pl-5 mt-2 text-sm text-foreground-light space-y-1">
<li>
Remove the <code className="text-code-inline">stripe</code> schema and all tables
</li>
<li>Delete all synced Stripe data</li>
<li>Remove the associated Edge Functions</li>
<li>Remove the scheduled sync jobs</li>
</ul>
<p className="mt-4 text-sm text-foreground-light font-medium">
This action cannot be undone.
</p>
</ConfirmationModal>
</>
)
}
export const StripeSyncEngineOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) {
return (
<>
<RequiredExtensionsSection />
<StripeSyncContent hideInstallCTA />
</>
)
}
return (
<IntegrationOverviewTab>
<div className="px-4 md:px-10 max-w-4xl space-y-4">
<StripeSyncContent />
</div>
</IntegrationOverviewTab>
)
}