Skip to content

Commit 498d051

Browse files
dnywhclaudeawaseem
authored
feat(studio): add project settings shortcuts (supabase#46352)
## What kind of change does this PR introduce? Feature. Resolves FE-3417. ## What is the current behavior? Project Settings has a top-level `G then ,` shortcut, but its subnavigation and repeated key/log drain actions do not have scoped keyboard shortcuts or visible shortcut tooltips. | Area | Current behaviour | | --- | --- | | Project Settings sidebar | Routes are click-only once users are inside Settings. | | API/JWT keys | Creation buttons do not expose keyboard shortcuts. | | Log Drains | Add/save destination actions do not expose keyboard shortcuts. | ## What is the new behavior? Adds scoped Project Settings navigation chords, shortcut tooltips on the sidebar rows, and page/action shortcuts for API keys, JWT standby keys, and Log Drains. | Area | New shortcut coverage | | --- | --- | | Project Settings sidebar | `S then G/C/I/N/W/K/J/L/A/D` for eligible in-section routes. | | API Keys | `Shift+P` and `Shift+S` open the publishable/secret key dialogs; `Mod+Enter` submits the open dialog. | | JWT Keys | `Shift+N` opens Create standby key; `Mod+Enter` submits the open dialog. | | Log Drains | `Shift+N` adds a destination when the primary action is available; `Mod+Enter` saves the open destination sheet. | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added keyboard shortcuts for Project Settings navigation and for actions in API Keys, JWT Keys, and Log Drains (open, create/submit). * **Improvements** * Dialogs and forms now support keyboard-triggered open and submit actions with improved enable/disable gating and updated settings menu composition; shortcuts appear in the shortcuts reference. * **Tests** * Added tests covering shortcut wiring and shortcut-driven open/submit behaviors across dialogs and action panels. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46352?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Ali Waseem <waseema393@gmail.com>
1 parent 5c85ec9 commit 498d051

23 files changed

Lines changed: 695 additions & 48 deletions
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { render, screen } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
import { CreatePublishableAPIKeyDialog } from './CreatePublishableAPIKeyDialog'
6+
import { CreateSecretAPIKeyDialog } from './CreateSecretAPIKeyDialog'
7+
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
8+
9+
const { mockSetVisible, mockShortcut, mockUseQueryState } = vi.hoisted(() => ({
10+
mockSetVisible: vi.fn(),
11+
mockShortcut: vi.fn(({ children }: any) => <div data-testid="shortcut">{children}</div>),
12+
mockUseQueryState: vi.fn(),
13+
}))
14+
15+
vi.mock('next/navigation', async () => {
16+
const actual = await vi.importActual<typeof import('next/navigation')>('next/navigation')
17+
return {
18+
...actual,
19+
useParams: () => ({ ref: 'project-ref' }),
20+
}
21+
})
22+
23+
vi.mock('nuqs', async () => {
24+
const actual = await vi.importActual<typeof import('nuqs')>('nuqs')
25+
return {
26+
...actual,
27+
useQueryState: mockUseQueryState,
28+
}
29+
})
30+
31+
vi.mock('@/components/ui/Shortcut', () => ({
32+
Shortcut: mockShortcut,
33+
}))
34+
35+
vi.mock('@/data/api-keys/api-key-create-mutation', () => ({
36+
useAPIKeyCreateMutation: () => ({ mutate: vi.fn(), isPending: false }),
37+
}))
38+
39+
describe('API key create dialogs', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks()
42+
mockUseQueryState.mockReturnValue(['', mockSetVisible])
43+
})
44+
45+
it('registers and surfaces the publishable key shortcut on the visible trigger', async () => {
46+
const user = userEvent.setup()
47+
48+
render(<CreatePublishableAPIKeyDialog />)
49+
50+
expect(mockShortcut).toHaveBeenCalledWith(
51+
expect.objectContaining({
52+
id: SHORTCUT_IDS.API_KEYS_NEW_PUBLISHABLE,
53+
onTrigger: expect.any(Function),
54+
side: 'bottom',
55+
}),
56+
undefined
57+
)
58+
59+
await user.click(screen.getByRole('button', { name: 'New publishable key' }))
60+
61+
expect(mockSetVisible).toHaveBeenCalledWith('publishable')
62+
})
63+
64+
it('registers and surfaces the publishable key submit shortcut on the primary action', () => {
65+
mockUseQueryState.mockReturnValue(['publishable', mockSetVisible])
66+
67+
render(<CreatePublishableAPIKeyDialog />)
68+
69+
expect(mockShortcut).toHaveBeenCalledWith(
70+
expect.objectContaining({
71+
id: SHORTCUT_IDS.API_KEYS_CREATE_PUBLISHABLE,
72+
onTrigger: expect.any(Function),
73+
options: { enabled: true },
74+
side: 'top',
75+
}),
76+
undefined
77+
)
78+
expect(screen.getByRole('button', { name: 'Create Publishable API key' })).toBeInTheDocument()
79+
})
80+
81+
it('registers and surfaces the secret key shortcut on the visible trigger', async () => {
82+
const user = userEvent.setup()
83+
84+
render(<CreateSecretAPIKeyDialog />)
85+
86+
expect(mockShortcut).toHaveBeenCalledWith(
87+
expect.objectContaining({
88+
id: SHORTCUT_IDS.API_KEYS_NEW_SECRET,
89+
onTrigger: expect.any(Function),
90+
side: 'bottom',
91+
}),
92+
undefined
93+
)
94+
95+
await user.click(screen.getByRole('button', { name: 'New secret key' }))
96+
97+
expect(mockSetVisible).toHaveBeenCalledWith('secret')
98+
})
99+
100+
it('registers and surfaces the secret key submit shortcut on the primary action', () => {
101+
mockUseQueryState.mockReturnValue(['secret', mockSetVisible])
102+
103+
render(<CreateSecretAPIKeyDialog />)
104+
105+
expect(mockShortcut).toHaveBeenCalledWith(
106+
expect.objectContaining({
107+
id: SHORTCUT_IDS.API_KEYS_CREATE_SECRET,
108+
onTrigger: expect.any(Function),
109+
options: { enabled: true },
110+
side: 'top',
111+
}),
112+
undefined
113+
)
114+
expect(screen.getByRole('button', { name: 'Create API key' })).toBeInTheDocument()
115+
})
116+
})

apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyDialog.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
22
import { Plus } from 'lucide-react'
33
import { useParams } from 'next/navigation'
44
import { parseAsString, useQueryState } from 'nuqs'
5+
import { useRef } from 'react'
56
import { SubmitHandler, useForm } from 'react-hook-form'
67
import {
78
Button,
@@ -13,7 +14,6 @@ import {
1314
DialogSection,
1415
DialogSectionSeparator,
1516
DialogTitle,
16-
DialogTrigger,
1717
Form,
1818
FormControl,
1919
FormField,
@@ -22,7 +22,9 @@ import {
2222
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
2323
import * as z from 'zod'
2424

25+
import { Shortcut } from '@/components/ui/Shortcut'
2526
import { useAPIKeyCreateMutation } from '@/data/api-keys/api-key-create-mutation'
27+
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
2628

2729
const FORM_ID = 'create-publishable-api-key'
2830
const SCHEMA = z.object({
@@ -37,13 +39,15 @@ export interface CreatePublishableAPIKeyDialogProps {
3739
export const CreatePublishableAPIKeyDialog = () => {
3840
const params = useParams()
3941
const projectRef = params?.ref as string
42+
const formRef = useRef<HTMLFormElement>(null)
4043

4144
const [visible, setVisible] = useQueryState('new', parseAsString.withDefault(''))
4245

4346
const onOpenChange = (value: boolean) => {
4447
if (value) setVisible('publishable')
4548
else setVisible('')
4649
}
50+
const openDialog = () => setVisible('publishable')
4751

4852
const defaultValues = { name: '', description: '' }
4953

@@ -76,11 +80,16 @@ export const CreatePublishableAPIKeyDialog = () => {
7680

7781
return (
7882
<Dialog open={visible === 'publishable'} onOpenChange={onOpenChange}>
79-
<DialogTrigger asChild>
80-
<Button type="default" icon={<Plus />}>
83+
<Shortcut
84+
id={SHORTCUT_IDS.API_KEYS_NEW_PUBLISHABLE}
85+
onTrigger={openDialog}
86+
side="bottom"
87+
tooltipOpen={visible === 'publishable' ? false : undefined}
88+
>
89+
<Button type="default" icon={<Plus />} onClick={openDialog}>
8190
New publishable key
8291
</Button>
83-
</DialogTrigger>
92+
</Shortcut>
8493
<DialogContent>
8594
<DialogHeader>
8695
<DialogTitle>Create new publishable API key</DialogTitle>
@@ -94,6 +103,7 @@ export const CreatePublishableAPIKeyDialog = () => {
94103
<DialogSection className="flex flex-col gap-4">
95104
<Form {...form}>
96105
<form
106+
ref={formRef}
97107
className="flex flex-col gap-4"
98108
id={FORM_ID}
99109
onSubmit={form.handleSubmit(onSubmit)}
@@ -132,9 +142,16 @@ export const CreatePublishableAPIKeyDialog = () => {
132142
</Form>
133143
</DialogSection>
134144
<DialogFooter>
135-
<Button form={FORM_ID} htmlType="submit" loading={isCreatingAPIKey}>
136-
Create Publishable API key
137-
</Button>
145+
<Shortcut
146+
id={SHORTCUT_IDS.API_KEYS_CREATE_PUBLISHABLE}
147+
onTrigger={() => formRef.current?.requestSubmit()}
148+
options={{ enabled: visible === 'publishable' && !isCreatingAPIKey }}
149+
side="top"
150+
>
151+
<Button form={FORM_ID} htmlType="submit" loading={isCreatingAPIKey}>
152+
Create Publishable API key
153+
</Button>
154+
</Shortcut>
138155
</DialogFooter>
139156
</DialogContent>
140157
</Dialog>

apps/studio/components/interfaces/APIKeys/CreateSecretAPIKeyDialog.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
22
import { useParams } from 'common'
33
import { Plus, ShieldCheck } from 'lucide-react'
44
import { parseAsString, useQueryState } from 'nuqs'
5+
import { useRef } from 'react'
56
import { useForm, type SubmitHandler } from 'react-hook-form'
67
import { toast } from 'sonner'
78
import {
@@ -17,7 +18,6 @@ import {
1718
DialogSection,
1819
DialogSectionSeparator,
1920
DialogTitle,
20-
DialogTrigger,
2121
Form,
2222
FormControl,
2323
FormField,
@@ -26,7 +26,9 @@ import {
2626
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
2727
import * as z from 'zod'
2828

29+
import { Shortcut } from '@/components/ui/Shortcut'
2930
import { useAPIKeyCreateMutation } from '@/data/api-keys/api-key-create-mutation'
31+
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
3032

3133
const NAME_SCHEMA = z
3234
.string()
@@ -48,11 +50,13 @@ const SCHEMA = z.object({
4850
export const CreateSecretAPIKeyDialog = () => {
4951
const { ref: projectRef } = useParams()
5052
const [visible, setVisible] = useQueryState('new', parseAsString)
53+
const formRef = useRef<HTMLFormElement>(null)
5154

5255
const onOpenChange = (value: boolean) => {
5356
if (value) setVisible('secret')
5457
else setVisible('')
5558
}
59+
const openDialog = () => setVisible('secret')
5660

5761
const defaultValues = { name: '', description: '' }
5862
const form = useForm<z.infer<typeof SCHEMA>>({
@@ -82,11 +86,16 @@ export const CreateSecretAPIKeyDialog = () => {
8286

8387
return (
8488
<Dialog open={visible === 'secret'} onOpenChange={onOpenChange}>
85-
<DialogTrigger asChild>
86-
<Button type="default" className="mt-2" icon={<Plus />}>
89+
<Shortcut
90+
id={SHORTCUT_IDS.API_KEYS_NEW_SECRET}
91+
onTrigger={openDialog}
92+
side="bottom"
93+
tooltipOpen={visible === 'secret' ? false : undefined}
94+
>
95+
<Button type="default" className="mt-2" icon={<Plus />} onClick={openDialog}>
8796
New secret key
8897
</Button>
89-
</DialogTrigger>
98+
</Shortcut>
9099
<DialogContent>
91100
<DialogHeader>
92101
<DialogTitle>Create new secret API key</DialogTitle>
@@ -101,6 +110,7 @@ export const CreateSecretAPIKeyDialog = () => {
101110
<DialogSection className="flex flex-col gap-4">
102111
<Form {...form}>
103112
<form
113+
ref={formRef}
104114
className="flex flex-col gap-4"
105115
id={FORM_ID}
106116
onSubmit={form.handleSubmit(onSubmit)}
@@ -160,9 +170,16 @@ export const CreateSecretAPIKeyDialog = () => {
160170
</Alert>
161171
</DialogSection>
162172
<DialogFooter>
163-
<Button form={FORM_ID} htmlType="submit" loading={isCreatingAPIKey}>
164-
Create API key
165-
</Button>
173+
<Shortcut
174+
id={SHORTCUT_IDS.API_KEYS_CREATE_SECRET}
175+
onTrigger={() => formRef.current?.requestSubmit()}
176+
options={{ enabled: visible === 'secret' && !isCreatingAPIKey }}
177+
side="top"
178+
>
179+
<Button form={FORM_ID} htmlType="submit" loading={isCreatingAPIKey}>
180+
Create API key
181+
</Button>
182+
</Shortcut>
166183
</DialogFooter>
167184
</DialogContent>
168185
</Dialog>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { render, screen } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
import { ActionPanel } from './action-panel'
6+
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
7+
8+
const { mockShortcut } = vi.hoisted(() => ({
9+
mockShortcut: vi.fn(({ children }: any) => <div data-testid="shortcut">{children}</div>),
10+
}))
11+
12+
vi.mock('@/components/ui/Shortcut', () => ({
13+
Shortcut: mockShortcut,
14+
}))
15+
16+
describe('ActionPanel', () => {
17+
beforeEach(() => {
18+
vi.clearAllMocks()
19+
})
20+
21+
it('wraps the action button with a shortcut when one is provided', async () => {
22+
const user = userEvent.setup()
23+
const onClick = vi.fn()
24+
25+
render(
26+
<ActionPanel
27+
title="Create standby key"
28+
description="Set up a new key."
29+
buttonLabel="Create Standby Key"
30+
onClick={onClick}
31+
loading={false}
32+
shortcutId={SHORTCUT_IDS.JWT_KEYS_CREATE_STANDBY}
33+
/>
34+
)
35+
36+
expect(mockShortcut).toHaveBeenCalledWith(
37+
expect.objectContaining({
38+
id: SHORTCUT_IDS.JWT_KEYS_CREATE_STANDBY,
39+
onTrigger: onClick,
40+
side: 'bottom',
41+
options: { enabled: true },
42+
}),
43+
undefined
44+
)
45+
46+
await user.click(screen.getByRole('button', { name: 'Create Standby Key' }))
47+
48+
expect(onClick).toHaveBeenCalled()
49+
})
50+
})

0 commit comments

Comments
 (0)