mirror of
https://github.com/supabase/supabase.git
synced 2026-05-09 02:09:50 -04:00
f06b877ac6
Closes [FE-3173](https://linear.app/supabase/issue/FE-3173/add-keyboard-shortcuts-to-auth-users-page) ## Shortcuts | Key | Action | |---|---| | `Shift+F` | Focus search input | | `F C` | Reset filters | | `Shift+R` | Refresh users | | `S C` | Reset sort to default | | `Mod+A` | Toggle selection on all loaded users | | `Mod+Backspace` | Open bulk-delete confirm modal | | `Esc` | Clear row selection + cell focus | | `Esc` (panel open) | Close user details panel | | `↑` / `↓` | Move focus into the grid; native arrow nav after | | `Enter` (row focused) | Open user details panel | | `I U` | Open Create user modal | | `I I` | Open Send invitation modal | ## Test plan - [ ] `Shift+F` focuses the search input - [ ] `F C` clears keywords, user type, providers - [ ] In the search input: Esc clears value, Esc again blurs - [ ] `Shift+R` refreshes - [ ] `S C` resets sort; no-op at default - [ ] `Mod+A` toggles all loaded users when ≤ 20 are loaded - [ ] `Mod+Backspace` opens the delete confirmation when a selection exists - [ ] `↑` / `↓` from cold load enters the grid; subsequent arrows navigate cells - [ ] `Enter` on a focused row opens the panel - [ ] `Esc` with panel open closes it; without panel, clears selection + cell focus - [ ] `I U` opens the Create user modal - [ ] `I I` opens the Send invitation modal - [ ] All shortcuts appear in `Cmd+K` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Comprehensive keyboard shortcuts for user management (focus search, refresh, reset filters, bulk select, open delete modal, close panel). * Improved keyboard navigation in the user list with cell-level movement and Enter-to-select behavior. * Search input: Escape clears search/keywords and it can be focused programmatically. * Shortcut hint badges added to "Send invitation" / "Create new user" dropdown items. * **Chores** * Centralized refresh behavior for consistent interaction. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
204 lines
6.0 KiB
TypeScript
204 lines
6.0 KiB
TypeScript
import { AuthUsersSearchSubmittedEvent } from 'common/telemetry-constants'
|
|
import { Search, X } from 'lucide-react'
|
|
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs'
|
|
import { Dispatch, forwardRef, SetStateAction } from 'react'
|
|
import {
|
|
Button,
|
|
cn,
|
|
Select_Shadcn_,
|
|
SelectContent_Shadcn_,
|
|
SelectGroup_Shadcn_,
|
|
SelectItem_Shadcn_,
|
|
SelectSeparator_Shadcn_,
|
|
SelectTrigger_Shadcn_,
|
|
SelectValue_Shadcn_,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from 'ui'
|
|
import { Input } from 'ui-patterns/DataInputs/Input'
|
|
|
|
import {
|
|
PHONE_NUMBER_LEFT_PREFIX_REGEX,
|
|
SpecificFilterColumn,
|
|
UUIDV4_LEFT_PREFIX_REGEX,
|
|
} from './Users.constants'
|
|
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
|
|
import { onSearchInputEscape } from '@/lib/keyboard'
|
|
|
|
const getSearchPlaceholder = (column: SpecificFilterColumn): string => {
|
|
switch (column) {
|
|
case 'id':
|
|
return 'Search by user ID'
|
|
case 'email':
|
|
return 'Search by email'
|
|
case 'name':
|
|
return 'Search by name'
|
|
case 'phone':
|
|
return 'Search by phone'
|
|
case 'freeform':
|
|
return 'Search by user ID, email, phone or name'
|
|
default:
|
|
return 'Search users...'
|
|
}
|
|
}
|
|
|
|
interface UsersSearchProps {
|
|
search: string
|
|
setSearch: Dispatch<SetStateAction<string>>
|
|
improvedSearchEnabled?: boolean
|
|
telemetryProps: Omit<AuthUsersSearchSubmittedEvent['properties'], 'trigger'>
|
|
telemetryGroups: AuthUsersSearchSubmittedEvent['groups']
|
|
onSelectFilterColumn: (value: SpecificFilterColumn) => void
|
|
}
|
|
|
|
export const UsersSearch = forwardRef<HTMLInputElement, UsersSearchProps>(function UsersSearch(
|
|
{
|
|
search,
|
|
setSearch,
|
|
improvedSearchEnabled = false,
|
|
telemetryProps,
|
|
telemetryGroups,
|
|
onSelectFilterColumn,
|
|
},
|
|
ref
|
|
) {
|
|
const [, setSelectedId] = useQueryState(
|
|
'show',
|
|
parseAsString.withOptions({ history: 'push', clearOnDefault: true })
|
|
)
|
|
const [, setFilterKeywords] = useQueryState('keywords', { defaultValue: '' })
|
|
const [specificFilterColumn] = useQueryState<SpecificFilterColumn>(
|
|
'filter',
|
|
parseAsStringEnum<SpecificFilterColumn>([
|
|
'id',
|
|
'email',
|
|
'phone',
|
|
'name',
|
|
'freeform',
|
|
]).withDefault('email')
|
|
)
|
|
|
|
const { mutate: sendEvent } = useSendEventMutation()
|
|
|
|
const searchInvalid =
|
|
!search ||
|
|
specificFilterColumn === 'freeform' ||
|
|
specificFilterColumn === 'email' ||
|
|
specificFilterColumn === 'name'
|
|
? false
|
|
: specificFilterColumn === 'id'
|
|
? !search.match(UUIDV4_LEFT_PREFIX_REGEX)
|
|
: !search.match(PHONE_NUMBER_LEFT_PREFIX_REGEX)
|
|
|
|
const onSubmitSearch = () => {
|
|
const s = search.trim().toLocaleLowerCase()
|
|
setFilterKeywords(s)
|
|
setSelectedId(null)
|
|
sendEvent({
|
|
action: 'auth_users_search_submitted',
|
|
properties: {
|
|
trigger: 'search_input',
|
|
...telemetryProps,
|
|
keywords: s,
|
|
},
|
|
groups: telemetryGroups,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center">
|
|
<div className="text-xs h-[26px] flex items-center px-1.5 border border-strong rounded-l-md bg-surface-300">
|
|
<Search size={14} />
|
|
</div>
|
|
|
|
<Select_Shadcn_
|
|
value={specificFilterColumn}
|
|
onValueChange={(v) => onSelectFilterColumn(v as typeof specificFilterColumn)}
|
|
>
|
|
<SelectTrigger_Shadcn_
|
|
size="tiny"
|
|
className={cn(
|
|
'w-[130px] bg-transparent! rounded-none -ml-px',
|
|
specificFilterColumn === 'freeform' && 'text-warning'
|
|
)}
|
|
>
|
|
<SelectValue_Shadcn_ />
|
|
</SelectTrigger_Shadcn_>
|
|
<SelectContent_Shadcn_>
|
|
<SelectGroup_Shadcn_>
|
|
<SelectItem_Shadcn_ value="id" className="text-xs">
|
|
User ID
|
|
</SelectItem_Shadcn_>
|
|
<SelectItem_Shadcn_ value="email" className="text-xs">
|
|
Email address
|
|
</SelectItem_Shadcn_>
|
|
{improvedSearchEnabled && (
|
|
<SelectItem_Shadcn_ value="name" className="text-xs">
|
|
Name
|
|
</SelectItem_Shadcn_>
|
|
)}
|
|
<SelectItem_Shadcn_ value="phone" className="text-xs">
|
|
Phone number
|
|
</SelectItem_Shadcn_>
|
|
{!improvedSearchEnabled && (
|
|
<>
|
|
<SelectSeparator_Shadcn_ />
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<SelectItem_Shadcn_ value="freeform" className="text-xs">
|
|
Unified search
|
|
</SelectItem_Shadcn_>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="w-64 text-center">
|
|
Search by all columns at once, including mid-string search. May impact database
|
|
performance if you have many users.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
</SelectGroup_Shadcn_>
|
|
</SelectContent_Shadcn_>
|
|
</Select_Shadcn_>
|
|
|
|
<Input
|
|
ref={ref}
|
|
size="tiny"
|
|
containerClassName="w-[245px] rounded-l-none -ml-px"
|
|
className={cn(
|
|
'bg-transparent',
|
|
searchInvalid ? 'text-red-900 dark:border-red-900' : '',
|
|
search.length > 1 && 'pr-6'
|
|
)}
|
|
placeholder={getSearchPlaceholder(specificFilterColumn)}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.code === 'Enter' || e.code === 'NumpadEnter') {
|
|
if (!searchInvalid) onSubmitSearch()
|
|
return
|
|
}
|
|
onSearchInputEscape(search, () => {
|
|
setSearch('')
|
|
setFilterKeywords('')
|
|
})(e)
|
|
}}
|
|
actions={
|
|
search ? (
|
|
<Button
|
|
size="tiny"
|
|
type="text"
|
|
className="p-0 h-5 w-5"
|
|
icon={<X className={cn(searchInvalid ? 'text-red-900' : '')} />}
|
|
onClick={() => {
|
|
setSearch('')
|
|
setFilterKeywords('')
|
|
}}
|
|
/>
|
|
) : null
|
|
}
|
|
/>
|
|
</div>
|
|
)
|
|
})
|