Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions static/app/components/commandPalette/context.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -8,6 +15,11 @@ type CommandPaletteProviderProps = {children: React.ReactNode};

type CommandPaletteActions = CommandPaletteActionWithKey[];

interface CommandPaletteQueryState {
query: string;
setQuery: React.Dispatch<React.SetStateAction<string>>;
}

type Unregister = () => void;
type CommandPaletteRegistration = (actions: CommandPaletteActionWithKey[]) => Unregister;

Expand All @@ -24,6 +36,7 @@ type CommandPaletteActionReducerAction =
const CommandPaletteRegistrationContext =
createContext<CommandPaletteRegistration | null>(null);
const CommandPaletteActionsContext = createContext<CommandPaletteActions | null>(null);
const CommandPaletteQueryContext = createContext<CommandPaletteQueryState | null>(null);

export function useCommandPaletteRegistration(): CommandPaletteRegistration {
const ctx = useContext(CommandPaletteRegistrationContext);
Expand All @@ -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
Expand Down Expand Up @@ -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[]) => {
Expand All @@ -88,7 +113,9 @@ export function CommandPaletteProvider({children}: CommandPaletteProviderProps)
return (
<CommandPaletteRegistrationContext.Provider value={registerActions}>
<CommandPaletteActionsContext.Provider value={actions}>
{children}
<CommandPaletteQueryContext.Provider value={queryState}>
{children}
</CommandPaletteQueryContext.Provider>
</CommandPaletteActionsContext.Provider>
</CommandPaletteRegistrationContext.Provider>
);
Expand Down
8 changes: 7 additions & 1 deletion static/app/components/commandPalette/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions static/app/components/commandPalette/ui/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 ? (
<IconWrap align="center" justify="center">
Expand All @@ -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<HTMLInputElement>(null);

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -68,7 +71,7 @@ function flattenActions(
}

export function useCommandPaletteState() {
const [query, setQuery] = useState('');
const {query, setQuery} = useCommandPaletteQueryState();
const actions = useCommandPaletteActions();
const [selectedAction, setSelectedAction] =
useState<CommandPaletteActionWithKey | null>(null);
Expand Down
176 changes: 176 additions & 0 deletions static/app/components/commandPalette/useDocsSearchActions.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ul>
<SetQuery query={query} />
{actions.map(action => (
<li key={action.key} data-type={action.type}>
{action.display.label}
</li>
))}
</ul>
);
}

function renderWithProvider(query: string) {
return render(
<CommandPaletteProvider>
<DocsSearchHarness query={query} />
</CommandPaletteProvider>
);
}

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('<mark>MCP</mark> 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(
<CommandPaletteProvider>
<DocsSearchHarness query="" />
</CommandPaletteProvider>
);

await waitFor(() => {
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
});
});
});
Loading
Loading