mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
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>
188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import {
|
|
parseAsArrayOf,
|
|
parseAsBoolean,
|
|
parseAsString,
|
|
parseAsStringEnum,
|
|
useQueryState,
|
|
} from 'nuqs'
|
|
import { Dispatch, RefObject, SetStateAction, useEffect, useState } from 'react'
|
|
import type { CellKeyboardEvent, DataGridHandle } from 'react-data-grid'
|
|
|
|
import { MAX_BULK_DELETE } from './Users.constants'
|
|
import type { User } from '@/data/auth/users-infinite-query'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
|
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
|
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
|
|
|
interface UseAuthUsersShortcutsParams {
|
|
gridRef: RefObject<DataGridHandle>
|
|
searchInputRef: RefObject<HTMLInputElement>
|
|
users: User[]
|
|
selectedUsers: Set<unknown>
|
|
setSelectedUsers: Dispatch<SetStateAction<Set<unknown>>>
|
|
setSearch: Dispatch<SetStateAction<string>>
|
|
onRefresh: () => void
|
|
}
|
|
|
|
interface GridCellKeyDownArgs {
|
|
mode: 'SELECT' | 'EDIT'
|
|
row: unknown
|
|
rowIdx: number
|
|
}
|
|
|
|
interface UseAuthUsersShortcutsResult {
|
|
onCellKeyDown: (args: GridCellKeyDownArgs, event: CellKeyboardEvent) => void
|
|
showDeleteModal: boolean
|
|
setShowDeleteModal: Dispatch<SetStateAction<boolean>>
|
|
}
|
|
|
|
export function useAuthUsersShortcuts({
|
|
gridRef,
|
|
searchInputRef,
|
|
users,
|
|
selectedUsers,
|
|
setSelectedUsers,
|
|
setSearch,
|
|
onRefresh,
|
|
}: UseAuthUsersShortcutsParams): UseAuthUsersShortcutsResult {
|
|
const [hasCellSelected, setHasCellSelected] = useState(false)
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
|
|
|
const [selectedId, setSelectedId] = useQueryState(
|
|
'show',
|
|
parseAsString.withOptions({ history: 'push', clearOnDefault: true })
|
|
)
|
|
const [, setFilterKeywords] = useQueryState('keywords', { defaultValue: '' })
|
|
const [, setFilterUserType] = useQueryState(
|
|
'userType',
|
|
parseAsStringEnum(['all', 'verified', 'unverified', 'anonymous']).withDefault('all')
|
|
)
|
|
const [, setSelectedProviders] = useQueryState(
|
|
'providers',
|
|
parseAsArrayOf(parseAsString, ',').withDefault([])
|
|
)
|
|
const [sortByValue, setSortByValue] = useQueryState('sortBy', {
|
|
defaultValue: 'created_at:desc',
|
|
})
|
|
const [, setInviteVisible] = useQueryState(
|
|
'invite',
|
|
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
|
|
)
|
|
const [, setCreateVisible] = useQueryState(
|
|
'new',
|
|
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
|
|
)
|
|
|
|
const { can: canCreateUsers } = useAsyncCheckPermissions(
|
|
PermissionAction.AUTH_EXECUTE,
|
|
'create_user'
|
|
)
|
|
const { can: canInviteUsers } = useAsyncCheckPermissions(
|
|
PermissionAction.AUTH_EXECUTE,
|
|
'invite_user'
|
|
)
|
|
const showSendInvitation = useIsFeatureEnabled('authentication:show_send_invitation')
|
|
|
|
useEffect(() => {
|
|
const el = gridRef.current?.element
|
|
if (!el) return
|
|
const onFocusIn = () => setHasCellSelected(true)
|
|
const onFocusOut = (event: FocusEvent) => {
|
|
if (!el.contains(event.relatedTarget as Node | null)) {
|
|
setHasCellSelected(false)
|
|
}
|
|
}
|
|
el.addEventListener('focusin', onFocusIn)
|
|
el.addEventListener('focusout', onFocusOut)
|
|
return () => {
|
|
el.removeEventListener('focusin', onFocusIn)
|
|
el.removeEventListener('focusout', onFocusOut)
|
|
}
|
|
}, [gridRef])
|
|
|
|
useShortcut(
|
|
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
|
() => {
|
|
searchInputRef.current?.focus()
|
|
searchInputRef.current?.select()
|
|
},
|
|
{ label: 'Search users' }
|
|
)
|
|
|
|
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
|
|
setSearch('')
|
|
setFilterKeywords('')
|
|
setFilterUserType('all')
|
|
setSelectedProviders([])
|
|
})
|
|
|
|
useShortcut(SHORTCUT_IDS.AUTH_USERS_REFRESH, onRefresh)
|
|
|
|
useShortcut(SHORTCUT_IDS.AUTH_USERS_CLEAR_SORT, () => setSortByValue('created_at:desc'), {
|
|
enabled: sortByValue !== 'created_at:desc',
|
|
})
|
|
|
|
useShortcut(
|
|
SHORTCUT_IDS.AUTH_USERS_TOGGLE_ALL_SELECTION,
|
|
() => {
|
|
if (selectedUsers.size === users.length) {
|
|
setSelectedUsers(new Set([]))
|
|
} else {
|
|
setSelectedUsers(new Set(users.map((u) => u.id)))
|
|
}
|
|
},
|
|
{ enabled: users.length > 0 && users.length <= MAX_BULK_DELETE }
|
|
)
|
|
|
|
useShortcut(SHORTCUT_IDS.AUTH_USERS_DELETE_SELECTED, () => setShowDeleteModal(true), {
|
|
enabled: selectedUsers.size > 0,
|
|
})
|
|
|
|
useShortcut(
|
|
SHORTCUT_IDS.AUTH_USERS_EXIT_SELECTION,
|
|
() => {
|
|
setSelectedUsers(new Set([]))
|
|
setHasCellSelected(false)
|
|
;(document.activeElement as HTMLElement | null)?.blur()
|
|
},
|
|
{ enabled: !selectedId && (selectedUsers.size > 0 || hasCellSelected) }
|
|
)
|
|
|
|
useShortcut(SHORTCUT_IDS.AUTH_USERS_CLOSE_PANEL, () => setSelectedId(null), {
|
|
enabled: !!selectedId,
|
|
})
|
|
|
|
const startGridNavigation = () => {
|
|
if (users.length === 0) return
|
|
gridRef.current?.selectCell({ idx: 1, rowIdx: 0 })
|
|
}
|
|
|
|
useShortcut(SHORTCUT_IDS.AUTH_USERS_START_NAV_DOWN, startGridNavigation, {
|
|
enabled: !hasCellSelected && users.length > 0,
|
|
})
|
|
|
|
useShortcut(SHORTCUT_IDS.AUTH_USERS_START_NAV_UP, startGridNavigation, {
|
|
enabled: !hasCellSelected && users.length > 0,
|
|
})
|
|
|
|
useShortcut(SHORTCUT_IDS.AUTH_USERS_CREATE_USER, () => setCreateVisible(true), {
|
|
enabled: canCreateUsers,
|
|
})
|
|
|
|
useShortcut(SHORTCUT_IDS.AUTH_USERS_INVITE_USER, () => setInviteVisible(true), {
|
|
enabled: canInviteUsers && showSendInvitation,
|
|
})
|
|
|
|
const onCellKeyDown = (args: GridCellKeyDownArgs, event: CellKeyboardEvent) => {
|
|
if (args.mode !== 'SELECT' || event.key !== 'Enter') return
|
|
const id = (args.row as { id?: unknown } | null | undefined)?.id
|
|
if (typeof id !== 'string') return
|
|
setSelectedId(id)
|
|
gridRef.current?.scrollToCell({ idx: 0, rowIdx: args.rowIdx })
|
|
}
|
|
|
|
return { onCellKeyDown, showDeleteModal, setShowDeleteModal }
|
|
}
|