Skip to content

Commit 8cafbe2

Browse files
JonasBaclaudegetsantry[bot]
authored
ref(cmdk): implement async actions (#112173)
Implements async actions inside our CMDK by allowing each action to define a resource callback function that returns `queryOptions`. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 5b0d6ef commit 8cafbe2

12 files changed

+469
-343
lines changed

static/app/components/commandPalette/__stories__/components.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,31 @@ import {useCallback} from 'react';
22

33
import {addSuccessMessage} from 'sentry/actionCreators/indicator';
44
import {CommandPaletteProvider} from 'sentry/components/commandPalette/context';
5+
import {useCommandPaletteActionsRegister} from 'sentry/components/commandPalette/context';
56
import type {
67
CommandPaletteAction,
7-
CommandPaletteActionCallbackWithKey,
8-
CommandPaletteActionLinkWithKey,
8+
CommandPaletteActionWithKey,
99
} from 'sentry/components/commandPalette/types';
1010
import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette';
11-
import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions';
1211
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
1312
import {useNavigate} from 'sentry/utils/useNavigate';
1413

1514
export function RegisterActions({actions}: {actions: CommandPaletteAction[]}) {
16-
useCommandPaletteActions(actions);
15+
useCommandPaletteActionsRegister(actions);
1716
return null;
1817
}
1918

2019
export function CommandPaletteDemo() {
2120
const navigate = useNavigate();
2221

2322
const handleAction = useCallback(
24-
(action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey) => {
23+
(action: CommandPaletteActionWithKey) => {
2524
if ('to' in action) {
2625
navigate(normalizeUrl(action.to));
27-
} else {
26+
} else if ('onAction' in action) {
2827
action.onAction();
28+
} else {
29+
// @TODO: implement async actions
2930
}
3031
},
3132
[navigate]

static/app/components/commandPalette/context.tsx

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,71 @@
1-
import {createContext, useCallback, useContext, useReducer} from 'react';
1+
import {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useEffect,
6+
useMemo,
7+
useReducer,
8+
} from 'react';
9+
import {uuid4} from '@sentry/core';
210

11+
import {slugify} from 'sentry/utils/slugify';
312
import {unreachable} from 'sentry/utils/unreachable';
413

514
import {CommandPaletteStateProvider} from './ui/commandPaletteStateContext';
6-
import type {CommandPaletteActionWithKey} from './types';
15+
import type {CommandPaletteAction, CommandPaletteActionWithKey} from './types';
16+
17+
function addKeysToActions(
18+
actions: CommandPaletteAction[]
19+
): CommandPaletteActionWithKey[] {
20+
return actions.map(action => {
21+
const kind = 'actions' in action ? 'group' : 'to' in action ? 'navigate' : 'callback';
22+
const actionKey = `${kind}:${slugify(action.display.label)}:${uuid4()}`;
23+
24+
if ('actions' in action) {
25+
return {
26+
...action,
27+
actions: addKeysToChildActions(actionKey, action.actions),
28+
key: actionKey,
29+
};
30+
}
31+
32+
return {
33+
...action,
34+
key: actionKey,
35+
};
36+
});
37+
}
38+
39+
function addKeysToChildActions(
40+
parentKey: string,
41+
actions: CommandPaletteAction[]
42+
): CommandPaletteActionWithKey[] {
43+
return actions.map(action => {
44+
const actionKey = `${parentKey}::${'actions' in action ? 'group' : 'to' in action ? 'navigate' : 'callback'}:${slugify(action.display.label)}`;
45+
46+
if ('actions' in action) {
47+
return {
48+
...action,
49+
actions: addKeysToChildActions(actionKey, action.actions),
50+
key: actionKey,
51+
};
52+
}
53+
54+
return {
55+
...action,
56+
key: actionKey,
57+
};
58+
});
59+
}
760

861
type CommandPaletteProviderProps = {children: React.ReactNode};
962
type CommandPaletteActions = CommandPaletteActionWithKey[];
1063

1164
type Unregister = () => void;
12-
type CommandPaletteRegistration = (actions: CommandPaletteActionWithKey[]) => Unregister;
65+
type CommandPaletteRegistration = {
66+
dispatch: React.Dispatch<CommandPaletteActionReducerAction>;
67+
registerActions: (actions: CommandPaletteAction[]) => Unregister;
68+
};
1369

1470
type CommandPaletteActionReducerAction =
1571
| {
@@ -25,7 +81,7 @@ const CommandPaletteRegistrationContext =
2581
createContext<CommandPaletteRegistration | null>(null);
2682
const CommandPaletteActionsContext = createContext<CommandPaletteActions | null>(null);
2783

28-
export function useCommandPaletteRegistration(): CommandPaletteRegistration {
84+
function useCommandPaletteRegistration(): CommandPaletteRegistration {
2985
const ctx = useContext(CommandPaletteRegistrationContext);
3086
if (ctx === null) {
3187
throw new Error(
@@ -65,6 +121,7 @@ function actionsReducer(
65121
return result;
66122
}
67123
case 'unregister':
124+
// @TODO(Jonas): this needs to support deep unregistering of actions
68125
return state.filter(action => !reducerAction.keys.includes(action.key));
69126
default:
70127
unreachable(type);
@@ -76,20 +133,45 @@ export function CommandPaletteProvider({children}: CommandPaletteProviderProps)
76133
const [actions, dispatch] = useReducer(actionsReducer, []);
77134

78135
const registerActions = useCallback(
79-
(newActions: CommandPaletteActionWithKey[]) => {
80-
dispatch({type: 'register', actions: newActions});
136+
(newActions: CommandPaletteAction[]) => {
137+
const actionsWithKeys = addKeysToActions(newActions);
138+
139+
dispatch({type: 'register', actions: actionsWithKeys});
81140
return () => {
82-
dispatch({type: 'unregister', keys: newActions.map(a => a.key)});
141+
dispatch({type: 'unregister', keys: actionsWithKeys.map(a => a.key)});
83142
};
84143
},
85144
[dispatch]
86145
);
87146

147+
const registerContext = useMemo(
148+
() => ({registerActions, dispatch}),
149+
[registerActions, dispatch]
150+
);
88151
return (
89-
<CommandPaletteRegistrationContext.Provider value={registerActions}>
152+
<CommandPaletteRegistrationContext.Provider value={registerContext}>
90153
<CommandPaletteActionsContext.Provider value={actions}>
91154
<CommandPaletteStateProvider>{children}</CommandPaletteStateProvider>
92155
</CommandPaletteActionsContext.Provider>
93156
</CommandPaletteRegistrationContext.Provider>
94157
);
95158
}
159+
160+
/**
161+
* Use this hook inside your page or feature component to register contextual actions with the global command palette.
162+
* Actions are registered on mount and automatically unregistered on unmount, so they only appear in the palette while
163+
* your component is rendered. This is ideal for page‑specific shortcuts.
164+
*
165+
* There are a few different types of actions you can register:
166+
*
167+
* - **Navigation actions**: Provide a `to` destination to navigate to when selected.
168+
* - **Callback actions**: Provide an `onAction` handler to execute when selected.
169+
* - **Nested actions**: Provide an `actions: CommandPaletteAction[]` array on a parent item to show a second level. Selecting the parent reveals its children.
170+
*
171+
* See the CommandPaletteAction type for more details on configuration.
172+
*/
173+
export function useCommandPaletteActionsRegister(actions: CommandPaletteAction[]) {
174+
const {registerActions} = useCommandPaletteRegistration();
175+
176+
useEffect(() => registerActions(actions), [actions, registerActions]);
177+
}
Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type {ReactNode} from 'react';
22
import type {LocationDescriptor} from 'history';
33

4-
interface CommonCommandPaletteAction {
4+
import type {UseQueryOptions} from 'sentry/utils/queryClient';
5+
6+
interface Action {
57
display: {
68
/** Primary text shown to the user */
79
label: string;
@@ -14,46 +16,97 @@ interface CommonCommandPaletteAction {
1416
keywords?: string[];
1517
}
1618

17-
export interface CommandPaletteActionLink extends CommonCommandPaletteAction {
19+
/**
20+
* Actions that can be returned from an async resource query.
21+
* Async results cannot themselves carry a `resource` — chained async lookups
22+
* are not supported. Use CommandPaletteAction for registering top-level actions.
23+
*/
24+
interface CommandPaletteAsyncResultGroup extends Action {
25+
actions: CommandPaletteAsyncResult[];
26+
}
27+
28+
export type CommandPaletteAsyncResult =
29+
| CommandPaletteActionLink
30+
| CommandPaletteActionCallback
31+
| CommandPaletteAsyncResultGroup;
32+
33+
export type CMDKQueryOptions = UseQueryOptions<
34+
any,
35+
Error,
36+
CommandPaletteAsyncResult[],
37+
any
38+
>;
39+
40+
export interface CommandPaletteActionLink extends Action {
1841
/** Navigate to a route when selected */
1942
to: LocationDescriptor;
2043
}
2144

22-
export interface CommandPaletteActionCallback extends CommonCommandPaletteAction {
45+
interface CommandPaletteActionCallback extends Action {
2346
/**
2447
* Execute a callback when the action is selected.
2548
* Use the `to` prop if you want to navigate to a route.
2649
*/
2750
onAction: () => void;
2851
}
2952

53+
interface CommandPaletteAsyncAction extends Action {
54+
/**
55+
* Execute a callback when the action is selected.
56+
* Use the `to` prop if you want to navigate to a route.
57+
*/
58+
resource: (query: string) => CMDKQueryOptions;
59+
}
60+
61+
interface CommandPaletteAsyncActionGroup extends Action {
62+
actions: CommandPaletteAction[];
63+
resource: (query: string) => CMDKQueryOptions;
64+
}
65+
3066
export type CommandPaletteAction =
3167
| CommandPaletteActionLink
3268
| CommandPaletteActionCallback
33-
| CommandPaletteActionGroup;
69+
| CommandPaletteActionGroup
70+
| CommandPaletteAsyncAction
71+
| CommandPaletteAsyncActionGroup;
3472

35-
export interface CommandPaletteActionGroup extends CommonCommandPaletteAction {
73+
interface CommandPaletteActionGroup extends Action {
3674
/** Nested actions to show when this action is selected */
37-
actions: Array<
38-
CommandPaletteActionLink | CommandPaletteActionCallback | CommandPaletteActionGroup
39-
>;
75+
actions: CommandPaletteAction[];
4076
}
4177

4278
// Internally, a key is added to the actions in order to track them for registration and selection.
43-
export type CommandPaletteActionLinkWithKey = CommandPaletteActionLink & {key: string};
44-
export type CommandPaletteActionCallbackWithKey = CommandPaletteActionCallback & {
79+
type CommandPaletteActionLinkWithKey = CommandPaletteActionLink & {key: string};
80+
type CommandPaletteActionCallbackWithKey = CommandPaletteActionCallback & {
4581
key: string;
4682
};
83+
type CommandPaletteAsyncActionWithKey = CommandPaletteAsyncAction & {
84+
key: string;
85+
};
86+
type CommandPaletteAsyncActionGroupWithKey = Omit<
87+
CommandPaletteAsyncActionGroup,
88+
'actions'
89+
> & {
90+
actions: CommandPaletteActionWithKey[];
91+
key: string;
92+
};
93+
4794
export type CommandPaletteActionWithKey =
95+
// Sync actions (to, callback, group)
4896
| CommandPaletteActionLinkWithKey
4997
| CommandPaletteActionCallbackWithKey
50-
| CommandPaletteActionGroupWithKey;
98+
| CommandPaletteActionGroupWithKey
99+
// Async actions
100+
| CommandPaletteAsyncActionWithKey
101+
| CommandPaletteAsyncActionGroupWithKey;
51102

52-
export interface CommandPaletteActionGroupWithKey extends CommandPaletteActionGroup {
103+
interface CommandPaletteActionGroupWithKey extends CommandPaletteActionGroup {
53104
actions: Array<
54105
| CommandPaletteActionLinkWithKey
55106
| CommandPaletteActionCallbackWithKey
56107
| CommandPaletteActionGroupWithKey
108+
| CommandPaletteAsyncActionWithKey
109+
| CommandPaletteAsyncActionGroupWithKey
57110
>;
58111
key: string;
59112
}

static/app/components/commandPalette/ui/commandPalette.spec.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import {useCallback} from 'react';
22

33
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
44

5+
jest.unmock('lodash/debounce');
6+
57
jest.mock('@tanstack/react-virtual', () => ({
68
useVirtualizer: ({count}: {count: number}) => {
79
const virtualItems = Array.from({length: count}, (_, index) => ({
@@ -23,17 +25,16 @@ jest.mock('@tanstack/react-virtual', () => ({
2325
import {closeModal} from 'sentry/actionCreators/modal';
2426
import * as modalActions from 'sentry/actionCreators/modal';
2527
import {CommandPaletteProvider} from 'sentry/components/commandPalette/context';
28+
import {useCommandPaletteActionsRegister} from 'sentry/components/commandPalette/context';
2629
import type {
2730
CommandPaletteAction,
28-
CommandPaletteActionCallbackWithKey,
29-
CommandPaletteActionLinkWithKey,
31+
CommandPaletteActionWithKey,
3032
} from 'sentry/components/commandPalette/types';
3133
import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette';
32-
import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions';
3334
import {useNavigate} from 'sentry/utils/useNavigate';
3435

3536
function RegisterActions({actions}: {actions: CommandPaletteAction[]}) {
36-
useCommandPaletteActions(actions);
37+
useCommandPaletteActionsRegister(actions);
3738
return null;
3839
}
3940

@@ -47,11 +48,13 @@ function GlobalActionsComponent({
4748
const navigate = useNavigate();
4849

4950
const handleAction = useCallback(
50-
(action: CommandPaletteActionLinkWithKey | CommandPaletteActionCallbackWithKey) => {
51+
(action: CommandPaletteActionWithKey) => {
5152
if ('to' in action) {
5253
navigate(action.to);
53-
} else {
54+
} else if ('onAction' in action) {
5455
action.onAction();
56+
} else {
57+
// @TODO: implement async actions
5558
}
5659
closeModal();
5760
},

0 commit comments

Comments
 (0)