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
9 changes: 5 additions & 4 deletions apps/design-system/content/docs/accessibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,17 @@ focus-visible:ring-offset-background
Uses `outline` (not `ring`) so it paints reliably on interactive `<tr>`s. Tailwind `ring` is `box-shadow`, which browsers often skip on `display: table-row` (notably Safari). Do not put `focus-ring` or raw `ring-*` on a `<tr>`, and do not add `outline-hidden` alongside `focus-inset`. `outline-hidden` sets `outline-style: none` and will hide the indicator.

```txt
outline: 2px solid transparent
outline-offset: -2px
transition-property: color, background-color, border-color, ...

&:focus-visible {
outline-style: solid
outline-width: 2px
outline-offset: -2px
outline-color: var(--ring)
border-radius: var(--radius-md)
}
```

`outline-hidden` is always on (not `focus-visible:`-prefixed) so mouse click does not show the browser’s default outline; the focus indicator replaces it for keyboard focus only.
`focus-ring` keeps `outline-hidden` always on so mouse clicks do not show the browser’s default outline. `focus-inset` reserves a transparent outline instead. Its transition property list deliberately excludes outline properties so the keyboard focus indicator appears immediately, even when a call site uses `transition-all`.

Rules:

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useReducedMotion } from 'common'
import { ChevronRight, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Expand Down Expand Up @@ -43,21 +43,36 @@ const DEFAULT_VALUES: TokenFormValues = {
permissions: {},
}

export const NewScopedTokenForm = ({
isPending,
onCreateToken,
onCancel,
}: {
isPending: boolean
onCreateToken: (values: TokenFormValues) => void
onCancel: () => void
}) => {
export interface NewScopedTokenFormHandle {
getAbandonmentContext: () => {
resourceAccess: TokenFormValues['resourceAccess']
formStep: 'form' | 'review'
isFormTouched: boolean
}
}

export const NewScopedTokenForm = forwardRef<
NewScopedTokenFormHandle,
{
isPending: boolean
onCreateToken: (values: TokenFormValues) => void
onCancel: () => void
}
>(({ isPending, onCreateToken, onCancel }, ref) => {
const form = useForm<TokenFormValues>({
resolver: zodResolver(TokenFormSchema),
defaultValues: DEFAULT_VALUES,
mode: 'onChange',
})
const [step, setStep] = useState<'form' | 'review'>('form')
const { isDirty } = form.formState
useImperativeHandle(ref, () => ({
getAbandonmentContext: () => ({
resourceAccess: form.getValues('resourceAccess'),
formStep: step,
isFormTouched: isDirty,
}),
}))
const [formValues, setFormValues] = useState<TokenFormValues>(DEFAULT_VALUES)
const [isCreateHintDismissed, setIsCreateHintDismissed] = useState(false)
const [missingPermissionsAttempts, setMissingPermissionsAttempts] = useState(0)
Expand Down Expand Up @@ -89,13 +104,15 @@ export const NewScopedTokenForm = ({
const isReducedMotionPreferred = useReducedMotion()
const isReducedMotionPreferredRef = useRef(isReducedMotionPreferred)
isReducedMotionPreferredRef.current = isReducedMotionPreferred
const onCancelRef = useRef(onCancel)
onCancelRef.current = onCancel

useEffect(() => {
if (isError) {
toast.error('Something went wrong, try again')
onCancel()
onCancelRef.current()
}
}, [onCancel, isError])
}, [isError])

useEffect(() => {
if (missingPermissionsAttempts === 0) return
Expand Down Expand Up @@ -267,4 +284,6 @@ export const NewScopedTokenForm = ({
</SheetFooter>
</>
)
}
})

NewScopedTokenForm.displayName = 'NewScopedTokenForm'
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,90 @@ describe('NewScopedTokenSheet', () => {
})
// Dialog has been closed
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
// Completing the flow via Done must not also emit a dismissed event
expect(mockTrack).not.toHaveBeenCalledWith(
'access_token_creation_sheet_dismissed',
expect.anything()
)
}, 10_000)

test('tracks dismissal with the in-progress resourceAccess and touched state on Cancel', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.click(await screen.findByRole('radio', { name: /Organization/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', {
resourceAccess: 'organization',
formStep: 'form',
isFormTouched: true,
trigger: 'user',
})
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})

test('tracks dismissal with the untouched default resourceAccess on Escape', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
const dialog = await screen.findByRole('dialog')
fireEvent.keyDown(dialog, { key: 'Escape', code: 'Escape' })
expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', {
resourceAccess: 'project',
formStep: 'form',
isFormTouched: false,
trigger: 'user',
})
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})

test('tracks the review step when the sheet is dismissed from the review screen', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.type(await screen.findByLabelText('Name'), 'test')
fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' }))
fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' }))
fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
fireEvent.click(await screen.findByRole('option', { name: 'Project 1' }))
await expandPermissionCategory('Project')
fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false }))
fireEvent.click(await screen.findByRole('option', { name: 'Read' }))
fireEvent.click(await screen.findByRole('button', { name: 'Review access' }))
await screen.findByText('Medium risk')
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', {
resourceAccess: 'project',
formStep: 'review',
isFormTouched: true,
trigger: 'user',
})
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
}, 10_000)

test('tracks a permissions load error as the dismissal trigger and closes the sheet', async () => {
addAPIMock({
method: 'get',
// @ts-expect-error Studio API is missing from types
path: '/scoped-access-token-permissions',
response: () => HttpResponse.json({ message: 'unavailable' }, { status: 500 }),
})
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await waitFor(() =>
expect(mockTrack).toHaveBeenCalledWith('access_token_creation_sheet_dismissed', {
resourceAccess: 'project',
formStep: 'form',
isFormTouched: false,
trigger: 'permissions_load_error',
})
)
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
const dismissedCalls = mockTrack.mock.calls.filter(
([event]) => event === 'access_token_creation_sheet_dismissed'
)
expect(dismissedCalls).toHaveLength(1)
})

// Organization scope tests
test('requires an organization when scope is Organization', async () => {
renderSheet()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
Expand All @@ -12,7 +12,7 @@ import {

import { selectionToScopes } from '../AccessToken.permissions'
import { ExperimentalTokenDropdown } from '../Classic/ExperimentalTokenDropdown'
import { NewScopedTokenForm } from './Form/NewScopedTokenForm'
import { NewScopedTokenForm, type NewScopedTokenFormHandle } from './Form/NewScopedTokenForm'
import { getExpiryDate, type TokenFormValues } from './Form/NewScopedTokenForm.utils'
import { NewScopedTokenSuccess } from './Form/NewScopedTokenSuccess'
import { TokenDocsButtons } from './TokenDocsButtons'
Expand All @@ -35,6 +35,7 @@ interface NewScopedTokenSheetProps {
export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedTokenSheetProps) => {
const [isOpen, setIsOpen] = useState(false)
const track = useTrack()
const formRef = useRef<NewScopedTokenFormHandle>(null)
const { mutate: createToken, isPending: isCreatingScopedToken } =
useScopedAccessTokenCreateMutation()
const { mutate: createClassicToken, isPending: isCreatingClassicToken } =
Expand Down Expand Up @@ -102,21 +103,30 @@ export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedToke
})
}

const trackDismissed = (trigger: 'user' | 'permissions_load_error') => {
const abandonmentContext = formRef.current?.getAbandonmentContext()
track('access_token_creation_sheet_dismissed', {
resourceAccess: abandonmentContext?.resourceAccess ?? 'project',
formStep: abandonmentContext?.formStep ?? 'form',
isFormTouched: abandonmentContext?.isFormTouched ?? false,
trigger,
})
}

// By default, if users created a token successfully, they can't click outside the sheet to close it
// as we need to make sure they copied the new token first
const handleOpenChange = (open: boolean, isSafe = false) => {
if (open === false && step === 'success' && !isSafe) return
if (open === false) {
track('access_token_creation_sheet_dismissed', {
// Can be non when users closes the sheet without completing the token creation
tokenType: createdToken?.tokenType ?? 'none',
step,
})
}
if (open === false && !isSafe) trackDismissed('user')
setStep('form')
setIsOpen(open)
}

const handlePermissionsLoadError = () => {
trackDismissed('permissions_load_error')
handleOpenChange(false, true)
}

return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<div className="flex items-center">
Expand Down Expand Up @@ -151,9 +161,10 @@ export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedToke
/>
) : (
<NewScopedTokenForm
ref={formRef}
isPending={isCreatingScopedToken || isCreatingClassicToken}
onCreateToken={handleCreate}
onCancel={() => handleOpenChange(false, true)}
onCancel={handlePermissionsLoadError}
/>
)}
</SheetContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export type ConditionalValue<T> =
// Schema Types - Modes
// ============================================================================

export const CONNECT_MODES = ['framework', 'direct', 'orm', 'mcp', 'server'] as const
export const CONNECT_MODES = ['framework', 'direct', 'orm', 'mcp', 'server', 'warehouse'] as const
export type ConnectMode = (typeof CONNECT_MODES)[number]

export interface ModeDefinition {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Box, Cable, Database, Server, Sparkles } from 'lucide-react'
import { Box, Cable, Database, Server, Sparkles, Warehouse } from 'lucide-react'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { cn } from 'ui'

Expand All @@ -11,6 +11,7 @@ const MODE_ICONS: Record<string, ReactNode> = {
orm: <Cable size={16} strokeWidth={1.5} />,
mcp: <Sparkles size={16} strokeWidth={1.5} />,
server: <Server size={16} strokeWidth={1.5} />,
warehouse: <Warehouse size={16} strokeWidth={1.5} />,
}

/** Maps mode count → container-query breakpoint used when collapsing to a single row. */
Expand Down
27 changes: 18 additions & 9 deletions apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useAvailableConnectModes } from './useAvailableConnectModes'
import { useConnectSheetParams } from './useConnectSheetParams'
import { useConnectSheetShortcut } from './useConnectSheetShortcut'
import { useConnectState } from './useConnectState'
import { WarehouseModePanel } from './WarehouseModePanel/WarehouseModePanel'
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
Expand Down Expand Up @@ -177,18 +178,26 @@ export const ConnectSheet = () => {
/>
</div>

{activeFields.length > 0 && (
{state.mode === 'warehouse' ? (
<div className="p-8">
<ConnectConfigSection
state={state}
activeFields={activeFields}
onFieldChange={handleFieldChange}
getFieldOptions={getFieldOptions}
/>
<WarehouseModePanel />
</div>
) : (
<>
{activeFields.length > 0 && (
<div className="p-8">
<ConnectConfigSection
state={state}
activeFields={activeFields}
onFieldChange={handleFieldChange}
getFieldOptions={getFieldOptions}
/>
</div>
)}

<ConnectStepsSection steps={resolvedSteps} state={state} projectKeys={projectKeys} />
</>
)}

<ConnectStepsSection steps={resolvedSteps} state={state} projectKeys={projectKeys} />
</div>
</SheetContent>
</Sheet>
Expand Down
Loading
Loading