From a27487fe580e7b6b7da1f68abecc49b395942550 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 22 May 2026 12:37:12 +1000 Subject: [PATCH] feat(studio): add keyboard shortcuts for platform webhooks (#46198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature. Resolves FE-3418 ## What is the current behaviour? Platform webhooks (org and project) have no keyboard shortcut coverage. Every action requires a mouse click. ## What is the new behaviour? Adds seven shortcuts across the four UI states of the platform webhooks pages: **List page** | Shortcut | Action | |---|---| | `Shift+F` | Focus search input | | `Shift+N` | Open "New endpoint" sheet | **Endpoint detail page** (when viewing a specific endpoint) | Shortcut | Action | |---|---| | `Shift+E` | Open the edit sheet | | `Shift+U` | Copy the endpoint URL | **Create / edit form sheet** | Shortcut | Action | |---|---| | `Mod+Enter` | Submit the form (create or save) | **Delivery details sheet** (when a delivery row is open) | Shortcut | Action | |---|---| | `Shift+R` | Retry the delivery (only active for non-success deliveries) | | `Shift+C` | Copy the active tab's payload (switches label between "Copy event payload" / "Copy response payload") | All shortcuts: - Are surfaced via `ShortcutTooltip` / `Shortcut` tooltips on their buttons - Appear in the keyboard shortcuts reference sheet (`Mod+/`) under a new **Platform Webhooks** group - Are gated so they only fire in the appropriate UI state (e.g. `Shift+E` is disabled while the edit sheet is already open) - Apply to both the org-level (`/org/[slug]/webhooks`) and project-level (`/project/[ref]/settings/webhooks`) pages as both use the same `PlatformWebhooksPage` component **Shared shortcuts reused** (no new IDs): `LIST_PAGE_FOCUS_SEARCH`, `LIST_PAGE_NEW_ITEM`, `ACTION_BAR_SAVE`. ## To test The platform webhooks UI is behind a feature flag for internal folks. Enable it in Studio via **Account → Feature Previews → Platform Webhooks**. The backend is not yet integrated, so you can test all the shortcuts on the 1–2 mock endpoints (and their deliveries) that appear. **List page** (`/org/[slug]/webhooks` or `/project/[ref]/settings/webhooks`): - [ ] `Shift+F` moves focus to the search input - [ ] `Shift+N` opens the "New endpoint" sheet (tooltip visible on hover of the button) **New endpoint sheet**: - [ ] Fill in a name and a valid URL, select at least one event type - [ ] `Mod+Enter` submits and creates the endpoint **Endpoint detail page**: - [ ] `Shift+E` opens the edit sheet (tooltip visible on the Edit button) - [ ] `Shift+U` copies the endpoint URL and shows a toast (tooltip visible on the copy icon next to the URL) **Edit sheet**: - [ ] `Mod+Enter` saves changes **Delivery details sheet** (click a delivery row to open): - [ ] `Shift+R` retries a failed/pending delivery (button and shortcut absent for successful deliveries) - [ ] On the **Event** tab: `Shift+C` copies the event payload, toast reads "Copied event payload" - [ ] On the **Response** tab: `Shift+C` copies the response payload, toast reads "Copied response payload" - [ ] Tooltip on both Copy buttons reflects the active tab label **Shortcuts reference sheet** (`Mod+/`): - [ ] A **Platform Webhooks** group appears when on an endpoint detail page or with the delivery sheet open with the relevant shortcuts listed - [ ] The basic shortcuts are shown under **List pages** when on the root Webhooks page --------- Co-authored-by: Claude Sonnet 4.6 --- .github/copilot-instructions.md | 2 +- .../studio-shortcuts.instructions.md | 35 +++++++--- .../PlatformWebhooksDeliveryDetailsSheet.tsx | 67 ++++++++++++++----- .../PlatformWebhooksEndpointDetails.test.tsx | 10 +++ .../PlatformWebhooksEndpointDetails.tsx | 34 ++++++++-- .../Webhooks/PlatformWebhooksEndpointList.tsx | 26 +++++-- .../PlatformWebhooksEndpointSheet.tsx | 14 +++- .../Webhooks/PlatformWebhooksPage.tsx | 31 +++++++-- .../ShortcutsReferenceSheet.test.tsx | 19 ++++++ .../ShortcutsReferenceSheet.tsx | 1 + .../studio/state/shortcuts/referenceGroups.ts | 1 + apps/studio/state/shortcuts/registry.ts | 10 +++ .../shortcuts/registry/platform-webhooks.ts | 48 +++++++++++++ 13 files changed, 254 insertions(+), 44 deletions(-) create mode 100644 apps/studio/state/shortcuts/registry/platform-webhooks.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d9819a0edf8..827b4111518 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -55,6 +55,6 @@ Path-specific rules in `.github/instructions/`: - **E2E Tests**: `studio-e2e-tests.instructions.md` — selector priority, anti-patterns (`waitForTimeout`, `force: true`) - **Composition Patterns**: `studio-composition-patterns.instructions.md` — avoid boolean props, use compound components - **shadcn/Radix Components**: `studio-shadcn-components.instructions.md` — accessibility handled by primitives, do not flag -- **Keyboard Shortcuts**: `studio-shortcuts.instructions.md` — shared registry usage, discovery, collision checks +- **Keyboard Shortcuts**: `studio-shortcuts.instructions.md` — shortcut registry pattern, search-input escape handler, when to flag missing coverage These files are scoped to `apps/studio/` and applied automatically during reviews. diff --git a/.github/instructions/studio-shortcuts.instructions.md b/.github/instructions/studio-shortcuts.instructions.md index c11944993c5..174d4dff366 100644 --- a/.github/instructions/studio-shortcuts.instructions.md +++ b/.github/instructions/studio-shortcuts.instructions.md @@ -15,23 +15,42 @@ When Studio UI changes introduce or materially alter repeated user actions, cons - PR adds a primary repeated action, toolbar action, list/table operation, or sub-page navigation without considering shortcut coverage. - PR adds a one-off `keydown` listener for a normal Studio action instead of using the shortcut registry and `useShortcut`. - PR registers a shortcut but does not expose it via `ShortcutTooltip`, `ShortcutBadge`, or command-menu badge where the action is visible. -- PR wires `useShortcut` and `ShortcutTooltip` separately for a single visible element instead of using the `` wrapper. - PR uses `G then ...` for a non-navigation action. -- PR adds a broad `Mod+letter` shortcut that overlaps common browser, editor, system, copy/save/search, or devtools behavior. +- PR adds a broad `Mod+letter` shortcut that overlaps common browser, editor, system, copy/save/search, or devtools behaviour. - PR adds a shortcut without checking existing registry and non-registry listeners for collisions. -- PR adds a search or filter input with custom Escape handling instead of `onSearchInputEscape` from `@/lib/keyboard`. +- PR adds a search/filter `` without `onKeyDown={onSearchInputEscape(...)}` — see **Search Inputs** below. ## Preferred Pattern - Add definitions in `apps/studio/state/shortcuts/registry.ts` or `apps/studio/state/shortcuts/registry/*`. -- Add or reuse a cheatsheet group in `apps/studio/state/shortcuts/referenceGroups.ts` when a shortcut belongs to a new surface; prefer existing groups for global actions, navigation, and established feature surfaces. - Register with `useShortcut`. -- For a single visible element that owns the action (button, icon button, menu trigger), prefer the `` wrapper in `apps/studio/components/ui/Shortcut.tsx` — it binds `useShortcut` and `ShortcutTooltip` from one `id` so the hotkey and tooltip can't drift. Drop down to `useShortcut` + `ShortcutTooltip`/`ShortcutBadge` separately only when the trigger and the visible affordance live on different elements. - Gate availability with `enabled`. -- Use `showInSettings: false` for contextual shortcuts that only work inside a page state, panel, sheet, or selected-row mode. - Surface visible actions with `ShortcutTooltip` or `ShortcutBadge`. -- For sheet-owned actions, mount the shortcut from the sheet or a sheet-owned hook; gate with `enabled` when the action only applies while the sheet is open. See `apps/studio/components/interfaces/ConnectSheet/useConnectSheetShortcut.ts`. -- For search/filter inputs, wire `onKeyDown` to `onSearchInputEscape(value, setValue)` from `@/lib/keyboard` so Escape clears the value, then blurs on a second press, and doesn't bubble to a parent dialog/popover. Don't re-implement this with a local `keydown` listener. - Prefer scoped, mnemonic sequential chords over global modifier chords. +- Set `showInSettings: false` on contextual shortcuts (scoped to a specific page state, sheet, or panel). +- When a shortcut group should appear in the reference sheet (`Mod+/`), add the group key to `SHORTCUT_REFERENCE_GROUP_ORDER` in `apps/studio/state/shortcuts/referenceGroups.ts` and a human label to `GROUP_LABELS` in `ShortcutsReferenceSheet.tsx`. +- For sheet-scoped shortcuts (active only while a `` is open), mount `useShortcut` inside the sheet component gated by the `open` prop — see `apps/studio/components/interfaces/ConnectSheet/useConnectSheetShortcut.ts` as the canonical example. + +## Search Inputs + +Every `` used as a search or filter field must include the staged-Escape handler from `apps/studio/lib/keyboard.ts`: + +```tsx +import { onSearchInputEscape } from '@/lib/keyboard' + +; setQuery(e.target.value)} + onKeyDown={onSearchInputEscape(query, setQuery)} +/> +``` + +Behaviour: + +- **Escape while the input has a value** → clears the value, keeps focus (so a second Escape then blurs) +- **Escape while the input is empty** → blurs the input +- Stops propagation on Escape so the keystroke does not accidentally close a parent dialog or sheet + +When pairing with `useShortcut(LIST_PAGE_FOCUS_SEARCH, ...)` to focus a search input via keyboard, always also add `onSearchInputEscape` on the same input — focus and escape-to-blur are always a pair. Canonical implementation context: `apps/studio/state/shortcuts/registry.ts`, `apps/studio/state/shortcuts/useShortcut.tsx`, and `apps/studio/components/ui/Shortcut*.tsx` diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx index 24d65ad0eeb..fec8c1a2999 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx @@ -22,6 +22,10 @@ import type { WebhookDelivery } from './PlatformWebhooks.types' import { formatDeliveryStatus, statusBadgeVariant } from './PlatformWebhooksView.utils' import { getStatusLevel } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils' import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { Shortcut } from '@/components/ui/Shortcut' +import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' interface PlatformWebhooksDeliveryDetailsSheetProps { deliveryAttempt: number | null @@ -51,6 +55,18 @@ export const PlatformWebhooksDeliveryDetailsSheet = ({ const retryableDelivery = selectedDelivery && selectedDelivery.status !== 'success' ? selectedDelivery : null + const activePayload = + deliveryDetailsTab === 'event' ? deliveryEventPayload : deliveryResponsePayload + const activePayloadLabel = deliveryDetailsTab === 'event' ? 'event payload' : 'response payload' + const copyPayloadShortcutLabel = + deliveryDetailsTab === 'event' ? 'Copy event payload' : 'Copy response payload' + + useShortcut( + SHORTCUT_IDS.PLATFORM_WEBHOOKS_COPY_PAYLOAD, + () => onCopy(activePayload, activePayloadLabel), + { enabled: open, label: copyPayloadShortcutLabel } + ) + return ( @@ -129,13 +145,18 @@ export const PlatformWebhooksDeliveryDetailsSheet = ({

Payload

- + +
@@ -167,13 +188,18 @@ export const PlatformWebhooksDeliveryDetailsSheet = ({
                   

Response payload

- + +
@@ -189,13 +215,18 @@ export const PlatformWebhooksDeliveryDetailsSheet = ({
 
         {retryableDelivery && (
           
-            
+              
+            
           
         )}
       
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx
index af10e11cd47..cb422c330ee 100644
--- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx
@@ -10,6 +10,14 @@ vi.mock('@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode', (
   DataTableColumnStatusCode: ({ value }: { value: number }) => {value},
 }))
 
+vi.mock('@/components/ui/ShortcutTooltip', () => ({
+  ShortcutTooltip: ({ children }: { children: unknown }) => <>{children},
+}))
+
+vi.mock('@/state/shortcuts/useShortcut', () => ({
+  useShortcut: vi.fn(),
+}))
+
 vi.mock('@/components/ui/ButtonTooltip', () => ({
   ButtonTooltip: ({
     icon,
@@ -51,6 +59,7 @@ describe('PlatformWebhooksEndpointDetails', () => {
         deliverySearch=""
         filteredDeliveries={allDeliveries}
         selectedEndpoint={selectedEndpoint}
+        onCopyUrl={vi.fn()}
         onDeliverySearchChange={vi.fn()}
         onOpenDelivery={vi.fn()}
         onRetryDelivery={vi.fn()}
@@ -106,6 +115,7 @@ describe('PlatformWebhooksEndpointDetails', () => {
         deliverySearch="project"
         filteredDeliveries={projectDeliveries}
         selectedEndpoint={selectedEndpoint}
+        onCopyUrl={vi.fn()}
         onDeliverySearchChange={vi.fn()}
         onOpenDelivery={vi.fn()}
         onRetryDelivery={vi.fn()}
diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx
index 560c78cb5b0..cb4d871c814 100644
--- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx
+++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx
@@ -8,8 +8,8 @@ import {
   type PaginationState,
   type SortingState,
 } from '@tanstack/react-table'
-import { ChevronLeft, ChevronRight, RotateCcw, Search } from 'lucide-react'
-import { useEffect, useState, type ReactNode } from 'react'
+import { ChevronLeft, ChevronRight, Copy, RotateCcw, Search } from 'lucide-react'
+import { useEffect, useRef, useState, type ReactNode } from 'react'
 import {
   Badge,
   Button,
@@ -32,6 +32,10 @@ import { statusBadgeVariant } from './PlatformWebhooksView.utils'
 import { getStatusLevel } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils'
 import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
 import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
+import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
+import { onSearchInputEscape } from '@/lib/keyboard'
+import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
+import { useShortcut } from '@/state/shortcuts/useShortcut'
 
 interface DetailItemProps {
   label: string
@@ -50,6 +54,7 @@ interface PlatformWebhooksEndpointDetailsProps {
   deliverySearch: string
   filteredDeliveries: WebhookDelivery[]
   selectedEndpoint: WebhookEndpoint
+  onCopyUrl: () => void
   onDeliverySearchChange: (value: string) => void
   onOpenDelivery: (deliveryId: string) => void
   onRetryDelivery: (deliveryId: string) => void
@@ -150,6 +155,7 @@ export const PlatformWebhooksEndpointDetails = ({
   deliverySearch,
   filteredDeliveries,
   selectedEndpoint,
+  onCopyUrl,
   onDeliverySearchChange,
   onOpenDelivery,
   onRetryDelivery,
@@ -157,7 +163,12 @@ export const PlatformWebhooksEndpointDetails = ({
   const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0
   const hasName = selectedEndpoint.name.trim().length > 0
   const hasDescription = selectedEndpoint.description.trim().length > 0
+  const deliverySearchRef = useRef(null)
   const [sorting, setSorting] = useState(DEFAULT_DELIVERY_SORTING)
+
+  useShortcut(SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH, () => deliverySearchRef.current?.focus(), {
+    label: 'Search deliveries',
+  })
   const [pagination, setPagination] = useState({
     pageIndex: 0,
     pageSize: DELIVERIES_PAGE_SIZE,
@@ -198,8 +209,21 @@ export const PlatformWebhooksEndpointDetails = ({
             
{hasName && {selectedEndpoint.name}} - - {selectedEndpoint.url} + + {selectedEndpoint.url} + + + + +
{filteredEndpoints.length === 0 ? ( diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx index 8fb1d02d230..b13630f069f 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx @@ -42,8 +42,10 @@ import type { import { generateWebhookEndpointName } from './PlatformWebhooks.utils' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { InlineLink } from '@/components/ui/InlineLink' +import { Shortcut } from '@/components/ui/Shortcut' import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' import { httpEndpointUrlSchema } from '@/lib/validation/http-url' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' const endpointFormSchema = z .object({ @@ -583,9 +585,15 @@ export const PlatformWebhooksEndpointSheet = ({ - + form.handleSubmit(onSubmit)()} + > + + diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx index 86bfe195f9a..2c5a0cbe8cc 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx @@ -51,7 +51,10 @@ import { } from './PlatformWebhooksPage.utils' import { useIsPlatformWebhooksEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { InlineLink } from '@/components/ui/InlineLink' +import { Shortcut } from '@/components/ui/Shortcut' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' const PANEL_VALUES = ['create', 'edit'] as const @@ -295,6 +298,12 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage const isEndpointSheetOpen = panel === 'create' || (panel === 'edit' && !!selectedEndpoint) + useShortcut( + SHORTCUT_IDS.PLATFORM_WEBHOOKS_COPY_ENDPOINT_URL, + () => selectedEndpoint && handleCopy(selectedEndpoint.url, 'endpoint URL'), + { enabled: isEndpointView && !isEndpointSheetOpen } + ) + useEffect(() => { if (!selectedEndpoint && !!deliveryId) { setDeliveryId(null) @@ -324,16 +333,25 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage endpointActions={ selectedEndpoint ? ( <> - + +