From c6cb717dfc5dd8fa9a45e62fac862f5e0858eb8a Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Tue, 24 Mar 2026 16:00:46 -0700 Subject: [PATCH] Create dynamic command palette action registration --- .../app/components/commandPalette/context.tsx | 31 ++- .../app/components/commandPalette/types.tsx | 8 +- .../components/commandPalette/ui/content.tsx | 6 +- .../ui/useCommandPaletteState.tsx | 7 +- .../useDocsSearchActions.spec.tsx | 176 ++++++++++++++++++ .../commandPalette/useDocsSearchActions.tsx | 86 +++++++++ .../useDsnLookupActions.spec.tsx | 11 +- .../commandPalette/useDsnLookupActions.tsx | 4 +- .../useDynamicCommandPaletteAction.tsx | 58 ++++++ 9 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 static/app/components/commandPalette/useDocsSearchActions.spec.tsx create mode 100644 static/app/components/commandPalette/useDocsSearchActions.tsx create mode 100644 static/app/components/commandPalette/useDynamicCommandPaletteAction.tsx diff --git a/static/app/components/commandPalette/context.tsx b/static/app/components/commandPalette/context.tsx index 8ff17991cbeb39..fb8f438ab5b42c 100644 --- a/static/app/components/commandPalette/context.tsx +++ b/static/app/components/commandPalette/context.tsx @@ -1,4 +1,11 @@ -import {createContext, useCallback, useContext, useReducer} from 'react'; +import { + createContext, + useCallback, + useContext, + useMemo, + useReducer, + useState, +} from 'react'; import {unreachable} from 'sentry/utils/unreachable'; @@ -8,6 +15,11 @@ type CommandPaletteProviderProps = {children: React.ReactNode}; type CommandPaletteActions = CommandPaletteActionWithKey[]; +interface CommandPaletteQueryState { + query: string; + setQuery: React.Dispatch>; +} + type Unregister = () => void; type CommandPaletteRegistration = (actions: CommandPaletteActionWithKey[]) => Unregister; @@ -24,6 +36,7 @@ type CommandPaletteActionReducerAction = const CommandPaletteRegistrationContext = createContext(null); const CommandPaletteActionsContext = createContext(null); +const CommandPaletteQueryContext = createContext(null); export function useCommandPaletteRegistration(): CommandPaletteRegistration { const ctx = useContext(CommandPaletteRegistrationContext); @@ -43,6 +56,16 @@ export function useCommandPaletteActions(): CommandPaletteActionWithKey[] { return ctx; } +export function useCommandPaletteQueryState(): CommandPaletteQueryState { + const ctx = useContext(CommandPaletteQueryContext); + if (ctx === null) { + throw new Error( + 'useCommandPaletteQueryState must be wrapped in CommandPaletteProvider' + ); + } + return ctx; +} + function actionsReducer( state: CommandPaletteActionWithKey[], reducerAction: CommandPaletteActionReducerAction @@ -74,6 +97,8 @@ function actionsReducer( export function CommandPaletteProvider({children}: CommandPaletteProviderProps) { const [actions, dispatch] = useReducer(actionsReducer, []); + const [query, setQuery] = useState(''); + const queryState = useMemo(() => ({query, setQuery}), [query]); const registerActions = useCallback( (newActions: CommandPaletteActionWithKey[]) => { @@ -88,7 +113,9 @@ export function CommandPaletteProvider({children}: CommandPaletteProviderProps) return ( - {children} + + {children} + ); diff --git a/static/app/components/commandPalette/types.tsx b/static/app/components/commandPalette/types.tsx index e82a3485cb53a9..5f6bd78997e05d 100644 --- a/static/app/components/commandPalette/types.tsx +++ b/static/app/components/commandPalette/types.tsx @@ -5,12 +5,18 @@ export type CommandPaletteGroupKey = 'search-result' | 'navigate' | 'add' | 'hel interface CommonCommandPaletteAction { display: { - /** Primary text shown to the user */ + /** Primary text shown to the user. Used for fuzzy search and key generation. */ label: string; /** Additional context or description */ details?: string; /** Icon to render for this action */ icon?: ReactNode; + /** + * Optional rich label to render instead of the plain text `label`. + * When provided, this is displayed in the palette while `label` is still used + * for fuzzy search matching and key generation. + */ + labelNode?: ReactNode; }; /** Section to group the action in the palette */ groupingKey?: CommandPaletteGroupKey; diff --git a/static/app/components/commandPalette/ui/content.tsx b/static/app/components/commandPalette/ui/content.tsx index a57b63561cc97b..a914f5bc18a17e 100644 --- a/static/app/components/commandPalette/ui/content.tsx +++ b/static/app/components/commandPalette/ui/content.tsx @@ -10,6 +10,7 @@ import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette import {COMMAND_PALETTE_GROUP_KEY_CONFIG} from 'sentry/components/commandPalette/ui/constants'; import {CommandPaletteList} from 'sentry/components/commandPalette/ui/list'; import {useCommandPaletteState} from 'sentry/components/commandPalette/ui/useCommandPaletteState'; +import {useDocsSearchActions} from 'sentry/components/commandPalette/useDocsSearchActions'; import {useDsnLookupActions} from 'sentry/components/commandPalette/useDsnLookupActions'; import {SvgIcon} from 'sentry/icons/svgIcon'; import {unreachable} from 'sentry/utils/unreachable'; @@ -27,7 +28,7 @@ function actionToMenuItem( ): CommandPaletteActionMenuItem { return { key: action.key, - label: action.display.label, + label: action.display.labelNode ?? action.display.label, details: action.display.details, leadingItems: action.display.icon ? ( @@ -42,7 +43,8 @@ function actionToMenuItem( export function CommandPaletteContent() { const {actions, selectedAction, selectAction, clearSelection, query, setQuery} = useCommandPaletteState(); - useDsnLookupActions(query); + useDsnLookupActions(); + useDocsSearchActions(); const navigate = useNavigate(); const inputRef = useRef(null); diff --git a/static/app/components/commandPalette/ui/useCommandPaletteState.tsx b/static/app/components/commandPalette/ui/useCommandPaletteState.tsx index 54510ed40e9316..77b561e503f351 100644 --- a/static/app/components/commandPalette/ui/useCommandPaletteState.tsx +++ b/static/app/components/commandPalette/ui/useCommandPaletteState.tsx @@ -1,7 +1,10 @@ import {useMemo, useState} from 'react'; import type Fuse from 'fuse.js'; -import {useCommandPaletteActions} from 'sentry/components/commandPalette/context'; +import { + useCommandPaletteActions, + useCommandPaletteQueryState, +} from 'sentry/components/commandPalette/context'; import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; import {strGetFn} from 'sentry/components/search/sources/utils'; import {useFuzzySearch} from 'sentry/utils/fuzzySearch'; @@ -68,7 +71,7 @@ function flattenActions( } export function useCommandPaletteState() { - const [query, setQuery] = useState(''); + const {query, setQuery} = useCommandPaletteQueryState(); const actions = useCommandPaletteActions(); const [selectedAction, setSelectedAction] = useState(null); diff --git a/static/app/components/commandPalette/useDocsSearchActions.spec.tsx b/static/app/components/commandPalette/useDocsSearchActions.spec.tsx new file mode 100644 index 00000000000000..30bdf0053ebac5 --- /dev/null +++ b/static/app/components/commandPalette/useDocsSearchActions.spec.tsx @@ -0,0 +1,176 @@ +import {useEffect} from 'react'; + +import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; + +import { + CommandPaletteProvider, + useCommandPaletteActions as useRegisteredActions, + useCommandPaletteQueryState, +} from 'sentry/components/commandPalette/context'; +import {useDocsSearchActions} from 'sentry/components/commandPalette/useDocsSearchActions'; + +const mockQuery = jest.fn(); + +jest.mock('@sentry-internal/global-search', () => ({ + SentryGlobalSearch: jest.fn().mockImplementation(() => ({ + query: mockQuery, + })), +})); + +function SetQuery({query}: {query: string}) { + const {setQuery} = useCommandPaletteQueryState(); + useEffect(() => setQuery(query), [query, setQuery]); + return null; +} + +function DocsSearchHarness({query}: {query: string}) { + useDocsSearchActions(); + const actions = useRegisteredActions(); + + return ( +
    + + {actions.map(action => ( +
  • + {action.display.label} +
  • + ))} +
+ ); +} + +function renderWithProvider(query: string) { + return render( + + + + ); +} + +function makeHit(title: string, url: string, context1?: string) { + return { + id: title, + site: 'docs', + title, + text: '', + url, + context: {context1: context1 ?? ''}, + }; +} + +describe('useDocsSearchActions', () => { + beforeEach(() => { + mockQuery.mockReset(); + }); + + it('registers actions from search results', async () => { + mockQuery.mockResolvedValue([ + { + site: 'docs', + name: 'Documentation', + hits: [ + makeHit('Getting Started', 'https://docs.sentry.io/getting-started/', 'Docs'), + makeHit('Configuration', 'https://docs.sentry.io/configuration/', 'Docs'), + ], + }, + ]); + + renderWithProvider('getting started'); + + await waitFor(() => { + expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0); + }); + + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('Getting Started'); + expect(items[1]).toHaveTextContent('Configuration'); + expect(items[0]).toHaveAttribute('data-type', 'callback'); + }); + + it('strips HTML from labels', async () => { + mockQuery.mockResolvedValue([ + { + site: 'docs', + name: 'Documentation', + hits: [makeHit('MCP Dashboard', 'https://docs.sentry.io/mcp/')], + }, + ]); + + renderWithProvider('mcp'); + + await waitFor(() => { + expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0); + }); + + // The plain text label should have HTML stripped + expect(screen.getByRole('listitem')).toHaveTextContent('MCP Dashboard'); + }); + + it('does not search when query is shorter than 3 characters', async () => { + renderWithProvider('mc'); + + // Give debounce time to settle + await waitFor(() => { + expect(mockQuery).not.toHaveBeenCalled(); + }); + + expect(screen.queryAllByRole('listitem')).toHaveLength(0); + }); + + it('does not search when query is empty', async () => { + renderWithProvider(''); + + await waitFor(() => { + expect(mockQuery).not.toHaveBeenCalled(); + }); + + expect(screen.queryAllByRole('listitem')).toHaveLength(0); + }); + + it('limits results to 5', async () => { + mockQuery.mockResolvedValue([ + { + site: 'docs', + name: 'Documentation', + hits: Array.from({length: 10}, (_, i) => + makeHit(`Result ${i}`, `https://docs.sentry.io/${i}/`) + ), + }, + ]); + + renderWithProvider('result'); + + await waitFor(() => { + expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0); + }); + + expect(screen.getAllByRole('listitem')).toHaveLength(5); + }); + + it('clears actions when query is cleared', async () => { + mockQuery.mockResolvedValue([ + { + site: 'docs', + name: 'Documentation', + hits: [makeHit('Test Page', 'https://docs.sentry.io/test/')], + }, + ]); + + const {rerender} = renderWithProvider('test query'); + + await waitFor(() => { + expect(screen.getAllByRole('listitem')).toHaveLength(1); + }); + + rerender( + + + + ); + + await waitFor(() => { + expect(screen.queryAllByRole('listitem')).toHaveLength(0); + }); + }); +}); diff --git a/static/app/components/commandPalette/useDocsSearchActions.tsx b/static/app/components/commandPalette/useDocsSearchActions.tsx new file mode 100644 index 00000000000000..c7d66690cf6e86 --- /dev/null +++ b/static/app/components/commandPalette/useDocsSearchActions.tsx @@ -0,0 +1,86 @@ +import {Fragment, useCallback, useState} from 'react'; +import {SentryGlobalSearch} from '@sentry-internal/global-search'; + +import {makeCommandPaletteCallback} from 'sentry/components/commandPalette/makeCommandPaletteAction'; +import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; +import {useDynamicCommandPaletteAction} from 'sentry/components/commandPalette/useDynamicCommandPaletteAction'; +import {IconDocs} from 'sentry/icons'; + +const MIN_QUERY_LENGTH = 3; +const MAX_RESULTS = 5; + +const MARK_OPEN = ''; +const MARK_CLOSE = ''; +const MARK_PATTERN = new RegExp(`(${MARK_OPEN}.*?${MARK_CLOSE})`, 'g'); + +function stripHtml(html: string): string { + return html.replace(/<[^>]*>/g, ''); +} + +/** + * Converts `` highlight tags from Algolia into bold text. + * Falls back to plain text when there are no marks. + */ +function highlightMarks(html: string): React.ReactNode { + const parts = html.split(MARK_PATTERN); + + if (parts.length === 1) { + return stripHtml(html); + } + + return ( + + {parts.map((part, i) => { + if (part.startsWith(MARK_OPEN) && part.endsWith(MARK_CLOSE)) { + const text = part.slice(MARK_OPEN.length, -MARK_CLOSE.length); + return ( + + {text} + + ); + } + return stripHtml(part); + })} + + ); +} + +export function useDocsSearchActions(): void { + const [search] = useState(() => new SentryGlobalSearch(['docs', 'develop'])); + + const queryAction = useCallback( + async (query: string): Promise => { + if (query.length < MIN_QUERY_LENGTH) { + return []; + } + + const results = await search.query( + query, + {searchAllIndexes: true}, + {analyticsTags: ['source:command-palette']} + ); + + return results + .flatMap(section => section.hits) + .slice(0, MAX_RESULTS) + .map(hit => + makeCommandPaletteCallback({ + display: { + label: stripHtml(hit.title ?? ''), + labelNode: highlightMarks(hit.title ?? ''), + details: hit.context?.context1, + icon: , + }, + groupingKey: 'help', + keywords: [hit.context?.context1, hit.context?.context2].filter( + Boolean + ) as string[], + onAction: () => window.open(hit.url, '_blank', 'noreferrer'), + }) + ); + }, + [search] + ); + + useDynamicCommandPaletteAction(queryAction); +} diff --git a/static/app/components/commandPalette/useDsnLookupActions.spec.tsx b/static/app/components/commandPalette/useDsnLookupActions.spec.tsx index f3436487815812..cce4252a8063b6 100644 --- a/static/app/components/commandPalette/useDsnLookupActions.spec.tsx +++ b/static/app/components/commandPalette/useDsnLookupActions.spec.tsx @@ -1,3 +1,4 @@ +import {useEffect} from 'react'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; @@ -5,17 +6,25 @@ import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; import { CommandPaletteProvider, useCommandPaletteActions as useRegisteredActions, + useCommandPaletteQueryState, } from 'sentry/components/commandPalette/context'; import {useDsnLookupActions} from 'sentry/components/commandPalette/useDsnLookupActions'; const org = OrganizationFixture({features: ['cmd-k-dsn-lookup']}); +function SetQuery({query}: {query: string}) { + const {setQuery} = useCommandPaletteQueryState(); + useEffect(() => setQuery(query), [query, setQuery]); + return null; +} + function DsnLookupHarness({query}: {query: string}) { - useDsnLookupActions(query); + useDsnLookupActions(); const actions = useRegisteredActions(); return (
    + {actions.map(action => (
  • , ]; -export function useDsnLookupActions(query: string): void { +export function useDsnLookupActions(): void { + const {query} = useCommandPaletteQueryState(); const organization = useOrganization({allowNull: true}); const hasDsnLookup = organization?.features?.includes('cmd-k-dsn-lookup') ?? false; const isDsn = DSN_PATTERN.test(query); diff --git a/static/app/components/commandPalette/useDynamicCommandPaletteAction.tsx b/static/app/components/commandPalette/useDynamicCommandPaletteAction.tsx new file mode 100644 index 00000000000000..57427868e81c4e --- /dev/null +++ b/static/app/components/commandPalette/useDynamicCommandPaletteAction.tsx @@ -0,0 +1,58 @@ +import {useEffect, useRef, useState} from 'react'; + +import {useCommandPaletteQueryState} from 'sentry/components/commandPalette/context'; +import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; +import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions'; +import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; + +const DEFAULT_DEBOUNCE_MS = 300; + +interface UseDynamicCommandPaletteActionOptions { + /** + * Debounce delay in milliseconds. Defaults to 300. + */ + debounceMs?: number; +} + +/** + * Register actions that are dynamically generated from an async query. + * + * Reads the current command palette query from context, debounces it, + * and calls the provided async function to generate actions. Results are + * automatically registered with the command palette and cleaned up on + * unmount or when results change. + */ +export function useDynamicCommandPaletteAction( + queryAction: (query: string) => Promise, + options?: UseDynamicCommandPaletteActionOptions +): void { + const {debounceMs = DEFAULT_DEBOUNCE_MS} = options ?? {}; + const {query} = useCommandPaletteQueryState(); + const debouncedQuery = useDebouncedValue(query, debounceMs); + const [actions, setActions] = useState([]); + const versionRef = useRef(0); + + useEffect(() => { + if (debouncedQuery.length === 0) { + setActions([]); + return; + } + + const version = ++versionRef.current; + + queryAction(debouncedQuery).then( + results => { + if (version === versionRef.current) { + setActions(results); + } + }, + () => { + if (version === versionRef.current) { + setActions([]); + } + } + ); + }, [debouncedQuery, queryAction]); + + useCommandPaletteActions(actions); +}