Files
supabase/apps/studio/components/interfaces/Settings/API/ExposedFunctionSelector.tsx
T
ChloeGarciaMillerandandGildas Garcia e0d3489c87 fix: ESLint errors relating to accessibility in project settings (#50848)
## Problem

Some aria-label and Tooltip are missing for icon-only buttons.

The components are used in
- project settings / infrastructure
- integrations / data API / settings

## Solution

Improving accessibility by adding `aria-label` and `Tooltip`.

<!--
## Preview links

If relevant, include links to changed pages for easy review access.

Copy the preview base URL from the Vercel bot comment on this PR. Use
the following table as an example template.

| Site | Live | Preview | Search for |
| -------------- |
-------------------------------------------------------------------------
|
------------------------------------------------------------------------------------------------------------
| ----------------------------- |
| WWW | [/blog/your-post](https://supabase.com/blog/your-post) |
[/blog/your-post](https://zone-www-dot-com-git-branch-name-supabase.vercel.app/blog/your-post)
| unique phrase from the change |
| Docs |
[/docs/guides/your-page](https://supabase.com/docs/guides/your-page) |
[/docs/guides/your-page](https://docs-git-branch-name-supabase.vercel.app/docs/guides/your-page)
| unique phrase from the change |
| Studio | [/dashboard](https://supabase.com/dashboard) |
[/dashboard](https://studio-git-branch-name-supabase.vercel.app/dashboard)
| unique phrase from the change |
| Design system | [/design-system](https://supabase.com/design-system) |
[/design-system](https://design-system-git-branch-name-supabase.vercel.app/design-system)
| unique phrase from the change |
| UI library | [/library](https://supabase.com/library) |
[/library](https://ui-library-git-branch-name-supabase.vercel.app/library)
| unique phrase from the change |
| Knowledge base |
[/kb/guides/your-page](https://supabase.com/kb/guides/your-page) |
[/kb/guides/your-page](https://kb-git-branch-name-supabase.vercel.app/kb/guides/your-page)
| unique phrase from the change |
-->

<!-- ## Additional context

No visual changes have been made.

-->

## Review instructions

Provide a clear numbered procedure that the PR reviewer can walk
through.

1. Open the pages where the updated components are used
2. See that the tooltips appear correctly when interacting with the
relevant elements


## Checklist

Check all before review:

- [X] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [ ] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which references
[WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md)
and the docs
[CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md)
guide


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

* **Accessibility**
* API function, schema, and table selectors have clear accessible names
and tooltips.
* Selector status summaries are announced politely by screen readers,
including schema loading states and available counts.
* **UI**
* Simplified the layout of the “Automatically expose new tables” setting
without changing its behavior.
* Corrected the transition styling on the infrastructure flow button;
its reduced-motion behavior remains unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
2026-10-13 16:36:05 +02:00

321 lines
12 KiB
TypeScript

import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
import { Check, ChevronsUpDown, CircleAlert, Info } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
cn,
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { exposedFunctionCountsQueryOptions } from '@/data/privileges/exposed-function-counts-query'
import { exposedFunctionsInfiniteQueryOptions } from '@/data/privileges/exposed-functions-infinite-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { pluralize } from '@/lib/helpers'
interface ExposedFunctionSelectorProps {
disabled?: boolean
selectedSchemas: string[]
pendingAddFunctionNames: string[]
pendingRemoveFunctionNames: string[]
onTogglePendingAdd: (functionName: string) => void
onTogglePendingRemove: (functionName: string) => void
}
export const ExposedFunctionSelector = ({
disabled = false,
selectedSchemas,
pendingAddFunctionNames,
pendingRemoveFunctionNames,
onTogglePendingAdd,
onTogglePendingRemove,
}: ExposedFunctionSelectorProps) => {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
const { data: project } = useSelectedProjectQuery()
const scrollRootRef = useRef<HTMLDivElement | null>(null)
const [sentinelRef, entry] = useIntersectionObserver({
root: scrollRootRef.current,
threshold: 0,
rootMargin: '0px',
})
const { data: countsData, isPending: isCountsPending } = useQuery({
...exposedFunctionCountsQueryOptions({
projectRef: project?.ref,
connectionString: project?.connectionString,
selectedSchemas,
}),
placeholderData: keepPreviousData,
})
const pendingCount = pendingAddFunctionNames.length + pendingRemoveFunctionNames.length
const totalCount = countsData?.total_count ?? 0
const grantsCount = countsData?.grants_count ?? 0
const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
...exposedFunctionsInfiniteQueryOptions({
projectRef: project?.ref,
connectionString: project?.connectionString,
search: search.length === 0 ? undefined : debouncedSearch || undefined,
}),
placeholderData: search.length > 0 ? keepPreviousData : undefined,
})
const functions = useMemo(
() => data?.pages.flatMap((page) => page.functions) ?? [],
[data?.pages]
)
const pendingAddSet = useMemo(() => new Set(pendingAddFunctionNames), [pendingAddFunctionNames])
const pendingRemoveSet = useMemo(
() => new Set(pendingRemoveFunctionNames),
[pendingRemoveFunctionNames]
)
useEffect(() => {
if (!isPending && !isFetching && entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage])
let functionsSummary: string
if (isCountsPending) {
functionsSummary = 'Loading functions...'
} else if (totalCount === 0) {
functionsSummary = 'No functions available'
} else {
functionsSummary = `${grantsCount} of ${totalCount} functions exposed${
pendingCount > 0 ? `, ${pendingCount} pending ${pluralize(pendingCount, 'change')}` : ''
}`
}
return (
<Popover open={open} onOpenChange={setOpen} modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
size="small"
disabled={disabled}
className="w-full [&>span]:w-full pr-1! space-x-1"
iconRight={
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
}
aria-label="Select functions"
// Tooltip repeats the label; the description would read the name twice
aria-describedby={undefined}
>
<div className="w-full flex gap-1">
<p className="text-foreground-lighter" aria-live="polite">
{functionsSummary}
</p>
</div>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="end">
Select functions
</TooltipContent>
</Tooltip>
<PopoverContent
className="p-0 min-w-[200px] pointer-events-auto"
side="bottom"
align="start"
sameWidthAsTrigger
>
<Command shouldFilter={false}>
<CommandInput
className="text-xs"
placeholder="Find function..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandGroup>
{isPending ? (
<>
<div className="px-2 py-1">
<ShimmeringLoader className="py-2" />
</div>
<div className="px-2 py-1 w-4/5">
<ShimmeringLoader className="py-2" />
</div>
</>
) : isError ? (
<div className="flex items-center py-3 justify-center">
<p className="text-xs text-foreground-lighter">Failed to retrieve functions</p>
</div>
) : (
<>
{functions.length === 0 && (
<p className="text-xs text-center text-foreground-lighter py-3">
{search.length > 0 ? 'No functions found' : 'No functions available'}
</p>
)}
<ScrollArea
ref={scrollRootRef}
className={functions.length > 7 ? 'h-[210px]' : ''}
>
{functions.map((fn) => {
const key = `${fn.schema}.${fn.name}`
const isSchemaExposed = selectedSchemas.includes(fn.schema)
const hasPendingAdd = pendingAddSet.has(key)
const hasPendingRemove = pendingRemoveSet.has(key)
const isCustom = fn.status === 'custom'
const isGranted = fn.status === 'granted'
const isCustomNeutral = isCustom && !hasPendingAdd && !hasPendingRemove
const isExposed =
isSchemaExposed &&
(isCustom ? hasPendingAdd : isGranted ? !hasPendingRemove : hasPendingAdd)
const customGrantsTooltip = getCustomGrantsTooltip({
hasPendingAdd,
hasPendingRemove,
})
return (
<CommandItem
key={key}
value={key}
className={cn(
'w-full',
isSchemaExposed ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed!'
)}
onSelect={() => {
if (!isSchemaExposed) return
if (isCustom) {
if (hasPendingAdd) {
onTogglePendingAdd(key)
onTogglePendingRemove(key)
} else if (hasPendingRemove) {
onTogglePendingRemove(key)
onTogglePendingAdd(key)
} else {
onTogglePendingAdd(key)
}
return
}
if (isGranted) {
onTogglePendingRemove(key)
} else {
onTogglePendingAdd(key)
}
}}
>
<div className="w-full flex items-center gap-x-2">
<div className="w-4 shrink-0 flex items-center justify-center">
{isExposed && <Check size={16} className="text-primary shrink-0" />}
{!isSchemaExposed && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={-1}
aria-label="Schema not exposed"
className="inline-flex items-center text-foreground-muted hover:text-foreground-light"
>
<Info size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-[320px] text-xs">
The schema "{fn.schema}" must be exposed before enabling this
function.
</TooltipContent>
</Tooltip>
)}
</div>
<span
className={cn(
'truncate',
(!isSchemaExposed || isCustomNeutral) && 'text-foreground-muted',
isCustomNeutral && isSchemaExposed && 'text-warning'
)}
>
{key}
</span>
<div className="ml-auto flex items-center gap-x-2">
{isCustom && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'shrink-0 flex items-center justify-center hover:text-foreground-light',
isCustomNeutral && isSchemaExposed
? 'text-warning'
: 'text-foreground-muted'
)}
>
<CircleAlert size={14} />
</div>
</TooltipTrigger>
<TooltipContent
side="right"
className="max-w-[320px] text-xs pointer-events-none"
>
{customGrantsTooltip}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</CommandItem>
)
})}
<div ref={sentinelRef} className="h-1 -mt-1" />
{hasNextPage && (
<div className="px-2 py-1">
<ShimmeringLoader className="py-2" />
</div>
)}
</ScrollArea>
</>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
const getCustomGrantsTooltip = ({
hasPendingAdd,
hasPendingRemove,
}: {
hasPendingAdd: boolean
hasPendingRemove: boolean
}) => {
if (hasPendingAdd) {
return 'This function has custom grants. Saving will override them with standard Data API grants for anon, authenticated, and service_role. Select again to revoke all grants instead.'
}
if (hasPendingRemove) {
return 'This function has custom grants. Saving will revoke all grants for anon, authenticated, and service_role. Select again to override with standard Data API grants instead.'
}
return 'This function has custom grants. Select it to override with standard Data API grants for anon, authenticated, and service_role.'
}