mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
chore(studio): migrate combobox triggers (#50157)
## What kind of change does this PR introduce? Studio UI consistency refactor. ## What is the current behaviour? Several Studio comboboxes still build their triggers from `Button` and supply their own double-chevron icon. This duplicates trigger styling and allows these controls to drift from selects and other comboboxes. ## What is the new behaviour? - Migrates the PITR timezone, AWS region, and account timezone controls to `ComboboxTrigger` - Migrates the shared `SchemaSelector` and `FunctionSelector`, updating their Studio callsites together - Preserves the globe icon in both timezone controls - Exposes the correct combobox role and open state through the shared trigger - Tightens the tiny schema selector end padding so its chevron aligns with adjacent controls - Leaves organisation and project context switchers unchanged | Before | After | | --- | --- | | <img width="504" height="490" alt="CleanShot 2026-09-09 at 13 56 56@2x" src="https://github.com/user-attachments/assets/117a9169-88bf-4f9e-8302-9df9b911a307" /> | <img width="496" height="512" alt="CleanShot 2026-09-09 at 11 31 26@2x" src="https://github.com/user-attachments/assets/bc98cded-2723-4d20-9d8c-49630ea018af" /> | | <img width="1250" height="394" alt="CleanShot 2026-09-09 at 13 58 34@2x" src="https://github.com/user-attachments/assets/9106f924-88fd-40d4-88e3-8d0ddbb61d12" /> | <img width="1246" height="376" alt="CleanShot 2026-09-09 at 13 58 09@2x" src="https://github.com/user-attachments/assets/7894a254-f6f9-40bb-a312-9f1a5079f096" /> | ## To test On the [Studio preview](https://studio-staging-git-dnywh-choremigrate-combobox-680102-supabase.vercel.app): 1. Open **Database > Tables** and use the schema selector above the table. It should use a single down chevron, open normally, and update the selected schema. 2. Open **Authentication > Hooks > Add hook**, then select **Postgres** as the hook type. The **Postgres schema** and **Postgres function** selectors should use a single down chevron and continue to open and select normally. The PITR, AWS region, and account timezone callsites require the relevant plan, integration, or feature flag. When available, their triggers should use the same single down chevron, and both timezone controls should retain the globe icon. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **UI Improvements** * Standardized timezone, AWS region, database backup, function, and schema selectors with a consistent combobox interface. * Added clear visual feedback for open and closed selector states. * Preserved contextual icons and labels, including globe icons for timezone selections. * Improved accessibility with appropriate combobox semantics, accessible names, and state information. * Timezone settings are now available without an optional feature flag. * **Tests** * Updated end-to-end coverage for the standardized combobox controls. * Added coverage confirming schema selectors expose the selected schema as an accessible name. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
fe0b18efb8
commit
fce9d475ee
7 files changed
+74
-72
No files matched your search
@@ -1,11 +1,10 @@
|
||||
import { useFlag } from 'common'
|
||||
import { CheckIcon, ChevronsUpDown, Globe } from 'lucide-react'
|
||||
import { CheckIcon, Globe } from 'lucide-react'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
cn,
|
||||
ComboboxTrigger,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
@@ -35,11 +34,10 @@ import { useTrack } from '@/lib/telemetry/track'
|
||||
const AUTO_OPTION_VALUE = '__auto__'
|
||||
|
||||
export const TimezoneSettings = () => {
|
||||
const timezonePickerEnabled = useFlag('timezonePicker')
|
||||
const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
|
||||
const track = useTrack()
|
||||
const [open, setOpen] = useState(false)
|
||||
const listboxId = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
|
||||
|
||||
// Browser timezone is captured once and stays stable even when the user has
|
||||
// overridden the dashboard timezone — that's the value the "Auto detect"
|
||||
@@ -48,8 +46,6 @@ export const TimezoneSettings = () => {
|
||||
|
||||
const triggerLabel = useMemo(() => findTimezoneByIana(timezone)?.text ?? timezone, [timezone])
|
||||
|
||||
if (!timezonePickerEnabled) return null
|
||||
|
||||
const handleSelect = (nextStored: string) => {
|
||||
setTimezone(nextStored)
|
||||
const resolvedNext = nextStored || guessLocalTimezone()
|
||||
@@ -87,20 +83,19 @@ export const TimezoneSettings = () => {
|
||||
>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
role="combobox"
|
||||
<ComboboxTrigger
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
className="w-full justify-between"
|
||||
variant="default"
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
size="small"
|
||||
icon={<Globe />}
|
||||
iconRight={<ChevronsUpDown size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{isAutoDetected ? `Auto detect (${timezone})` : triggerLabel}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Globe aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{isAutoDetected ? `Auto detect (${timezone})` : triggerLabel}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent id={listboxId} className="w-[--radix-popover-trigger-width] p-0">
|
||||
<Command>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { Check } from 'lucide-react'
|
||||
import { useId, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
ComboboxTrigger,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
@@ -62,19 +62,15 @@ export const AwsRegionSelector = ({
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="default"
|
||||
role="combobox"
|
||||
<ComboboxTrigger
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
className={cn(!value && 'text-muted-foreground')}
|
||||
size="small"
|
||||
iconRight={
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" strokeWidth={1} />
|
||||
}
|
||||
>
|
||||
{value ?? 'Select a region'}
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent id={listboxId} className="p-0" sameWidthAsTrigger>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CheckIcon, ChevronsUpDown, Globe } from 'lucide-react'
|
||||
import { CheckIcon, Globe } from 'lucide-react'
|
||||
import { useId, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
ComboboxTrigger,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
@@ -36,20 +36,22 @@ export const TimezoneSelection = ({
|
||||
<div className="w-full">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
role="combobox"
|
||||
<ComboboxTrigger
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
className="w-[350px] justify-start"
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
className="w-[350px]"
|
||||
size="small"
|
||||
variant="default"
|
||||
icon={<Globe />}
|
||||
iconRight={<ChevronsUpDown size={14} strokeWidth={1.5} className="ml-auto" />}
|
||||
>
|
||||
{selectedTimezone
|
||||
? timezoneOptions.find((option) => option === selectedTimezone.text)
|
||||
: 'Select timezone...'}
|
||||
</Button>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Globe aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedTimezone
|
||||
? timezoneOptions.find((option) => option === selectedTimezone.text)
|
||||
: 'Select timezone...'}
|
||||
</span>
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent id={listboxId} className="w-[350px] p-0">
|
||||
<Command>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useParams } from 'common'
|
||||
import { uniqBy } from 'lodash'
|
||||
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
|
||||
import { Check, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
ComboboxTrigger,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
@@ -97,26 +98,22 @@ const FunctionSelector = ({
|
||||
{isSuccess && (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
<ComboboxTrigger
|
||||
size={size}
|
||||
disabled={!!disabled}
|
||||
variant="default"
|
||||
className={`w-full [&>span]:w-full ${size === 'small' ? 'py-1.5' : ''}`}
|
||||
iconRight={
|
||||
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
|
||||
}
|
||||
aria-expanded={open}
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
className={size === 'small' ? 'py-1.5' : undefined}
|
||||
>
|
||||
{value ? (
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">function:</p>
|
||||
<p className="text-foreground">{value}</p>
|
||||
</div>
|
||||
<span className="flex w-full gap-1">
|
||||
<span className="text-foreground-lighter">function:</span>
|
||||
<span className="text-foreground">{value}</span>
|
||||
</span>
|
||||
) : (
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">Select a function</p>
|
||||
</div>
|
||||
<span className="flex w-full gap-1 text-foreground-lighter">Select a function</span>
|
||||
)}
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" side="bottom" align="start" sameWidthAsTrigger>
|
||||
<Command>
|
||||
|
||||
@@ -74,6 +74,14 @@ const renderAndOpenSelector = async () => {
|
||||
}
|
||||
|
||||
describe('SchemaSelector', () => {
|
||||
it('gives the combobox an accessible name for the selected schema', async () => {
|
||||
mockProjectAndSchemas({ highAvailability: false })
|
||||
|
||||
customRender(<SchemaSelector selectedSchemaName="public" onSelectSchema={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByRole('combobox', { name: 'Schema public' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the multigres schema on high availability projects', async () => {
|
||||
mockProjectAndSchemas({ highAvailability: true })
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
|
||||
import { Check, Plus } from 'lucide-react'
|
||||
import { ComponentPropsWithoutRef, forwardRef, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
ComboboxTrigger,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
@@ -138,29 +139,32 @@ export const SchemaSelector = forwardRef<HTMLDivElement, SchemaSelectorProps>(
|
||||
{isSchemasSuccess && (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
<ComboboxTrigger
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
variant="default"
|
||||
data-testid="schema-selector"
|
||||
className={`w-full [&>span]:w-full pr-1! space-x-1`}
|
||||
iconRight={
|
||||
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
|
||||
aria-label={
|
||||
selectedSchemaName
|
||||
? `Schema ${selectedSchemaName === '*' ? 'All schemas' : selectedSchemaName}`
|
||||
: placeholderLabel
|
||||
}
|
||||
aria-expanded={open}
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
className={size === 'tiny' ? 'w-full pr-1.5!' : 'w-full'}
|
||||
>
|
||||
{selectedSchemaName ? (
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">schema</p>
|
||||
<p className="text-foreground">
|
||||
<span className="flex w-full gap-1">
|
||||
<span className="text-foreground-lighter">schema</span>
|
||||
<span className="text-foreground">
|
||||
{selectedSchemaName === '*' ? 'All schemas' : selectedSchemaName}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">{placeholderLabel}</p>
|
||||
</div>
|
||||
<span className="flex w-full gap-1 text-foreground-lighter">
|
||||
{placeholderLabel}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0 min-w-[200px] pointer-events-auto"
|
||||
|
||||
@@ -36,7 +36,7 @@ test.describe('RLS Policies', () => {
|
||||
).toBeVisible()
|
||||
|
||||
// Check schema selector is present
|
||||
await expect(page.getByRole('button', { name: 'schema public' })).toBeVisible()
|
||||
await expect(page.getByRole('combobox', { name: 'Schema public' })).toBeVisible()
|
||||
|
||||
// Check search/filter input is present
|
||||
await expect(page.getByPlaceholder('Filter tables and policies')).toBeVisible()
|
||||
@@ -75,7 +75,7 @@ test.describe('RLS Policies', () => {
|
||||
await navigateToPoliciesPage(page, ref)
|
||||
|
||||
// Click schema selector
|
||||
await page.getByRole('button', { name: 'schema public' }).click()
|
||||
await page.getByRole('combobox', { name: 'Schema public' }).click()
|
||||
|
||||
// Select auth schema
|
||||
await page.getByRole('option', { name: 'auth' }).click()
|
||||
@@ -85,7 +85,7 @@ test.describe('RLS Policies', () => {
|
||||
await expect(page.getByRole('heading', { name: 'users', exact: true })).toBeVisible()
|
||||
|
||||
// Switch back to public
|
||||
await page.getByRole('button', { name: 'schema auth' }).click()
|
||||
await page.getByRole('combobox', { name: 'Schema auth' }).click()
|
||||
await page.getByRole('option', { name: 'public', exact: true }).click()
|
||||
await page.waitForTimeout(1000)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user