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 ? ( <> - + +