Skip to content

Commit a389f27

Browse files
JonasBaclaude
andcommitted
ref(cmdk): Remove onAction prop in favor of internal action execution
CommandPalette now handles all action execution internally — navigating for 'to' actions (with shift-key new-tab support) and invoking the action-defined onAction callback for callback actions. Callers no longer need to implement action dispatch logic; the only integration point is the optional closeModal prop. This removes the indirection of consumers inspecting CollectionTreeNode shapes at call sites and centralises the execution model inside the component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c42400e commit a389f27

File tree

5 files changed

+58
-159
lines changed

5 files changed

+58
-159
lines changed

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

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,11 @@
1-
import {useCallback} from 'react';
2-
31
import {addSuccessMessage} from 'sentry/actionCreators/indicator';
4-
import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk';
5-
import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk';
6-
import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk';
7-
import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection';
2+
import {
3+
CMDKAction,
4+
CommandPaletteProvider,
5+
} from 'sentry/components/commandPalette/ui/cmdk';
86
import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette';
9-
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
10-
import {useNavigate} from 'sentry/utils/useNavigate';
117

128
export function CommandPaletteDemo() {
13-
const navigate = useNavigate();
14-
15-
const handleAction = useCallback(
16-
(
17-
action: CollectionTreeNode<CMDKActionData>,
18-
_options?: {modifierKeys?: {shiftKey: boolean}}
19-
) => {
20-
if ('to' in action) {
21-
navigate(normalizeUrl(action.to));
22-
} else if ('onAction' in action) {
23-
action.onAction();
24-
}
25-
},
26-
[navigate]
27-
);
28-
299
return (
3010
<CommandPaletteProvider>
3111
<CMDKAction display={{label: 'Go to Flex story'}} to="/stories/layout/flex/" />
@@ -39,7 +19,7 @@ export function CommandPaletteDemo() {
3919
onAction={() => addSuccessMessage('Child action executed')}
4020
/>
4121
</CMDKAction>
42-
<CommandPalette onAction={handleAction}>
22+
<CommandPalette>
4323
<CMDKAction display={{label: 'Issues List'}}>
4424
<CMDKAction
4525
display={{label: 'Select all'}}

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

Lines changed: 27 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Fragment, useCallback} from 'react';
1+
import {Fragment} from 'react';
22

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

@@ -24,47 +24,18 @@ jest.mock('@tanstack/react-virtual', () => ({
2424

2525
import {closeModal} from 'sentry/actionCreators/modal';
2626
import * as modalActions from 'sentry/actionCreators/modal';
27-
import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk';
28-
import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk';
29-
import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk';
30-
import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection';
27+
import {
28+
CMDKAction,
29+
CommandPaletteProvider,
30+
} from 'sentry/components/commandPalette/ui/cmdk';
3131
import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette';
3232
import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot';
33-
import {useNavigate} from 'sentry/utils/useNavigate';
34-
35-
function GlobalActionsComponent({
36-
children,
37-
onAction,
38-
}: {
39-
children?: React.ReactNode;
40-
onAction?: (
41-
action: CollectionTreeNode<CMDKActionData>,
42-
options?: {modifierKeys?: {shiftKey: boolean}}
43-
) => void;
44-
}) {
45-
const navigate = useNavigate();
46-
47-
const handleAction = useCallback(
48-
(
49-
action: CollectionTreeNode<CMDKActionData>,
50-
options?: {modifierKeys?: {shiftKey: boolean}}
51-
) => {
52-
if (onAction) {
53-
onAction(action, options);
54-
} else if ('to' in action) {
55-
navigate(action.to);
56-
} else if ('onAction' in action) {
57-
action.onAction();
58-
}
59-
closeModal();
60-
},
61-
[navigate, onAction]
62-
);
6333

34+
function GlobalActionsComponent({children}: {children?: React.ReactNode}) {
6435
return (
6536
<CommandPaletteProvider>
6637
{children}
67-
<CommandPalette onAction={handleAction} />
38+
<CommandPalette closeModal={closeModal} />
6839
</CommandPaletteProvider>
6940
);
7041
}
@@ -118,23 +89,27 @@ describe('CommandPalette', () => {
11889
expect(closeSpy).toHaveBeenCalledTimes(1);
11990
});
12091

121-
it('shift-enter on an internal link forwards modifier keys and closes modal', async () => {
92+
it('shift-enter on an internal link opens in a new tab and closes modal', async () => {
12293
const closeSpy = jest.spyOn(modalActions, 'closeModal');
123-
const onAction = jest.fn();
94+
const openSpy = jest.spyOn(window, 'open').mockReturnValue(null);
12495

12596
render(
126-
<GlobalActionsComponent onAction={onAction}>
97+
<GlobalActionsComponent>
12798
<CMDKAction to="/target/" display={{label: 'Go to route'}} />
12899
</GlobalActionsComponent>
129100
);
130101

131102
await screen.findByRole('textbox', {name: 'Search commands'});
132103
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
133104

134-
expect(onAction).toHaveBeenCalledWith(expect.objectContaining({to: '/target/'}), {
135-
modifierKeys: {shiftKey: true},
136-
});
105+
expect(openSpy).toHaveBeenCalledWith(
106+
expect.stringContaining('target'),
107+
'_blank',
108+
'noreferrer'
109+
);
137110
expect(closeSpy).toHaveBeenCalledTimes(1);
111+
112+
openSpy.mockRestore();
138113
});
139114

140115
it('shows internal and external trailing link indicators for link actions', async () => {
@@ -398,18 +373,6 @@ describe('CommandPalette', () => {
398373
const secondaryCallback = jest.fn();
399374
const closeSpy = jest.spyOn(modalActions, 'closeModal');
400375

401-
// Mirror the updated modal.tsx handleSelect: invoke callback, skip close when
402-
// action has children so the palette can push into the secondary actions.
403-
const handleAction = (action: CollectionTreeNode<CMDKActionData>) => {
404-
if ('onAction' in action) {
405-
action.onAction();
406-
if (action.children.length > 0) {
407-
return;
408-
}
409-
}
410-
closeModal();
411-
};
412-
413376
// Top-level groups become section headers (disabled), so the action-with-callback
414377
// must be a child item — matching how "Parent Group Action" works in allActions.
415378
render(
@@ -422,7 +385,7 @@ describe('CommandPalette', () => {
422385
/>
423386
</CMDKAction>
424387
</CMDKAction>
425-
<CommandPalette onAction={handleAction} />
388+
<CommandPalette closeModal={closeModal} />
426389
</CommandPaletteProvider>
427390
);
428391

@@ -522,7 +485,7 @@ describe('CommandPalette', () => {
522485
<CommandPaletteSlot name="task">
523486
<CMDKAction display={{label: 'Task Action'}} onAction={jest.fn()} />
524487
</CommandPaletteSlot>
525-
<CommandPalette onAction={jest.fn()} />
488+
<CommandPalette closeModal={jest.fn()} />
526489
</CommandPaletteSlot.Provider>
527490
</CommandPaletteProvider>
528491
);
@@ -541,9 +504,7 @@ describe('CommandPalette', () => {
541504
<CommandPaletteSlot name="task">
542505
<CMDKAction display={{label: 'Task Action'}} onAction={onAction} />
543506
</CommandPaletteSlot>
544-
<CommandPalette
545-
onAction={node => ('onAction' in node ? node.onAction() : null)}
546-
/>
507+
<CommandPalette closeModal={jest.fn()} />
547508
</CommandPaletteSlot.Provider>
548509
</CommandPaletteProvider>
549510
);
@@ -559,7 +520,7 @@ describe('CommandPalette', () => {
559520
<CommandPaletteSlot name="page">
560521
<CMDKAction display={{label: 'Page Action'}} onAction={jest.fn()} />
561522
</CommandPaletteSlot>
562-
<CommandPalette onAction={jest.fn()} />
523+
<CommandPalette closeModal={jest.fn()} />
563524
</CommandPaletteSlot.Provider>
564525
</CommandPaletteProvider>
565526
);
@@ -578,9 +539,7 @@ describe('CommandPalette', () => {
578539
<CommandPaletteSlot name="page">
579540
<CMDKAction display={{label: 'Page Action'}} onAction={onAction} />
580541
</CommandPaletteSlot>
581-
<CommandPalette
582-
onAction={node => ('onAction' in node ? node.onAction() : null)}
583-
/>
542+
<CommandPalette closeModal={jest.fn()} />
584543
</CommandPaletteSlot.Provider>
585544
</CommandPaletteProvider>
586545
);
@@ -606,7 +565,7 @@ describe('CommandPalette', () => {
606565
<CommandPaletteSlot name="page">
607566
<CMDKAction display={{label: 'Page Action'}} onAction={jest.fn()} />
608567
</CommandPaletteSlot>
609-
<CommandPalette onAction={jest.fn()} />
568+
<CommandPalette closeModal={jest.fn()} />
610569
</CommandPaletteSlot.Provider>
611570
</CommandPaletteProvider>
612571
);
@@ -630,7 +589,7 @@ describe('CommandPalette', () => {
630589
<CommandPaletteSlot name="task">
631590
<CMDKAction display={{label: 'Task Action'}} onAction={jest.fn()} />
632591
</CommandPaletteSlot>
633-
<CommandPalette onAction={jest.fn()} />
592+
<CommandPalette closeModal={jest.fn()} />
634593
</CommandPaletteSlot.Provider>
635594
</CommandPaletteProvider>
636595
);
@@ -659,7 +618,7 @@ describe('CommandPalette', () => {
659618

660619
render(
661620
<CommandPaletteProvider>
662-
<CommandPalette onAction={jest.fn()}>
621+
<CommandPalette closeModal={jest.fn()}>
663622
<ActionsViaGlobalSlot />
664623
</CommandPalette>
665624
</CommandPaletteProvider>
@@ -678,7 +637,7 @@ describe('CommandPalette', () => {
678637
<CommandPaletteProvider>
679638
<CMDKAction display={{label: 'Empty Group'}} />
680639
<CMDKAction display={{label: 'Real Action'}} onAction={jest.fn()} />
681-
<CommandPalette onAction={jest.fn()} />
640+
<CommandPalette closeModal={jest.fn()} />
682641
</CommandPaletteProvider>
683642
);
684643

@@ -692,7 +651,7 @@ describe('CommandPalette', () => {
692651
render(
693652
<CommandPaletteProvider>
694653
<CMDKAction display={{label: 'Direct Action'}} onAction={jest.fn()} />
695-
<CommandPalette onAction={jest.fn()} />
654+
<CommandPalette closeModal={jest.fn()} />
696655
</CommandPaletteProvider>
697656
);
698657

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import {
2727
useCommandPaletteDispatch,
2828
useCommandPaletteState,
2929
} from 'sentry/components/commandPalette/ui/commandPaletteStateContext';
30-
import {isExternalLocation} from 'sentry/components/commandPalette/ui/locationUtils';
30+
import {
31+
getLocationHref,
32+
isExternalLocation,
33+
} from 'sentry/components/commandPalette/ui/locationUtils';
3134
import {useCommandPaletteAnalytics} from 'sentry/components/commandPalette/useCommandPaletteAnalytics';
3235
import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton';
3336
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
@@ -36,7 +39,9 @@ import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults';
3639
import {t} from 'sentry/locale';
3740
import {fzf} from 'sentry/utils/search/fzf';
3841
import type {Theme} from 'sentry/utils/theme';
42+
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
3943
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
44+
import {useNavigate} from 'sentry/utils/useNavigate';
4045

4146
const MotionButton = motion.create(Button);
4247
const MotionIconSearch = motion.create(IconSearch);
@@ -66,15 +71,13 @@ type CMDKFlatItem = CollectionTreeNode<CMDKActionData> & {
6671
};
6772

6873
interface CommandPaletteProps {
69-
onAction: (
70-
action: CollectionTreeNode<CMDKActionData>,
71-
options?: {modifierKeys?: {shiftKey: boolean}}
72-
) => void;
7374
children?: React.ReactNode;
75+
closeModal?: () => void;
7476
}
7577

7678
export function CommandPalette(props: CommandPaletteProps) {
7779
const theme = useTheme();
80+
const navigate = useNavigate();
7881
const store = CMDKCollection.useStore();
7982

8083
const state = useCommandPaletteState();
@@ -205,6 +208,7 @@ export function CommandPalette(props: CommandPaletteProps) {
205208
disallowTypeAhead: true,
206209
});
207210

211+
const {closeModal} = props;
208212
const onActionSelection = useCallback(
209213
(
210214
key: string | number | null,
@@ -224,17 +228,29 @@ export function CommandPalette(props: CommandPaletteProps) {
224228
if ('onAction' in action) {
225229
// Invoke the callback but keep the modal open so users can select
226230
// secondary actions from the children that follow.
227-
props.onAction(action, options);
231+
action.onAction();
228232
}
229233
dispatch({type: 'push action', key: action.key, label: action.display.label});
230234
return;
231235
}
232236

233237
analytics.recordAction(action, resultIndex, '');
234238
dispatch({type: 'trigger action'});
235-
props.onAction(action, options);
239+
240+
if ('to' in action) {
241+
const normalizedTo = normalizeUrl(action.to);
242+
if (isExternalLocation(normalizedTo) || options?.modifierKeys?.shiftKey) {
243+
window.open(getLocationHref(normalizedTo), '_blank', 'noreferrer');
244+
} else {
245+
navigate(normalizedTo);
246+
}
247+
} else if ('onAction' in action) {
248+
action.onAction();
249+
}
250+
251+
closeModal?.();
236252
},
237-
[actions, analytics, dispatch, props]
253+
[actions, analytics, closeModal, dispatch, navigate]
238254
);
239255

240256
const resultsListRef = useRef<HTMLDivElement>(null);

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

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,14 @@
1-
import {useCallback} from 'react';
21
import {css} from '@emotion/react';
32

43
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
5-
import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk';
6-
import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection';
74
import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette';
85
import {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/ui/commandPaletteGlobalActions';
9-
import {
10-
getLocationHref,
11-
isExternalLocation,
12-
} from 'sentry/components/commandPalette/ui/locationUtils';
136
import type {Theme} from 'sentry/utils/theme';
14-
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
15-
import {useNavigate} from 'sentry/utils/useNavigate';
167

178
export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps) {
18-
const navigate = useNavigate();
19-
20-
const handleSelect = useCallback(
21-
(
22-
action: CollectionTreeNode<CMDKActionData>,
23-
options?: {modifierKeys?: {shiftKey: boolean}}
24-
) => {
25-
if ('to' in action) {
26-
const normalizedTo = normalizeUrl(action.to);
27-
if (isExternalLocation(normalizedTo) || options?.modifierKeys?.shiftKey) {
28-
window.open(getLocationHref(normalizedTo), '_blank', 'noreferrer');
29-
} else {
30-
navigate(normalizedTo);
31-
}
32-
} else if ('onAction' in action) {
33-
action.onAction();
34-
// When the action has children, the palette will push into them so the
35-
// user can select a secondary action — keep the modal open.
36-
if (action.children.length > 0) {
37-
return;
38-
}
39-
}
40-
closeModal();
41-
},
42-
[navigate, closeModal]
43-
);
44-
459
return (
4610
<Body>
47-
<CommandPalette onAction={handleSelect}>
11+
<CommandPalette closeModal={closeModal}>
4812
<GlobalCommandPaletteActions />
4913
</CommandPalette>
5014
</Body>

0 commit comments

Comments
 (0)