Skip to content

Commit 497dd7e

Browse files
JonasBaclaude
andcommitted
feat(cmdk): Invoke onAction callback for actions with children
When a CMDKAction has both an onAction callback and children, selecting it now invokes the callback immediately and keeps the modal open so the user can choose a secondary action from the children. Previously, actions with children were treated purely as navigation groups — the onAction callback was silently ignored. Now the callback fires before the palette pushes into the child list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9d70bc8 commit 497dd7e

File tree

3 files changed

+69
-1
lines changed

3 files changed

+69
-1
lines changed

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,64 @@ describe('CommandPalette', () => {
366366
});
367367
});
368368

369+
describe('action with onAction and children', () => {
370+
it('invokes callback, keeps modal open, and then shows children for secondary selection', async () => {
371+
const primaryCallback = jest.fn();
372+
const secondaryCallback = jest.fn();
373+
const closeSpy = jest.spyOn(modalActions, 'closeModal');
374+
375+
// Mirror the updated modal.tsx handleSelect: invoke callback, skip close when
376+
// action has children so the palette can push into the secondary actions.
377+
const handleAction = (action: CollectionTreeNode<CMDKActionData>) => {
378+
if ('onAction' in action) {
379+
action.onAction();
380+
if (action.children.length > 0) {
381+
return;
382+
}
383+
}
384+
closeModal();
385+
};
386+
387+
// Top-level groups become section headers (disabled), so the action-with-callback
388+
// must be a child item — matching how "Parent Group Action" works in allActions.
389+
render(
390+
<CommandPaletteProvider>
391+
<CMDKAction display={{label: 'Outer Group'}}>
392+
<CMDKAction display={{label: 'Primary Action'}} onAction={primaryCallback}>
393+
<CMDKAction
394+
display={{label: 'Secondary Action'}}
395+
onAction={secondaryCallback}
396+
/>
397+
</CMDKAction>
398+
</CMDKAction>
399+
<CommandPalette onAction={handleAction} />
400+
</CommandPaletteProvider>
401+
);
402+
403+
// Select the primary action (has both onAction and children)
404+
await userEvent.click(await screen.findByRole('option', {name: 'Primary Action'}));
405+
406+
// Callback should have been invoked
407+
expect(primaryCallback).toHaveBeenCalledTimes(1);
408+
409+
// Modal must remain open — no close call yet
410+
expect(closeSpy).not.toHaveBeenCalled();
411+
412+
// The palette should have pushed into the children
413+
expect(
414+
await screen.findByRole('option', {name: 'Secondary Action'})
415+
).toBeInTheDocument();
416+
expect(
417+
screen.queryByRole('option', {name: 'Primary Action'})
418+
).not.toBeInTheDocument();
419+
420+
// Selecting the secondary action should invoke its callback and close the modal
421+
await userEvent.click(screen.getByRole('option', {name: 'Secondary Action'}));
422+
expect(secondaryCallback).toHaveBeenCalledTimes(1);
423+
expect(closeSpy).toHaveBeenCalledTimes(1);
424+
});
425+
});
426+
369427
describe('query restoration', () => {
370428
it('drilling into a group clears the active query', async () => {
371429
render(<GlobalActionsComponent actions={allActions} />);
@@ -580,7 +638,7 @@ describe('CommandPalette', () => {
580638
it('a group with no children is omitted from the list', async () => {
581639
render(
582640
<CommandPaletteProvider>
583-
<CMDKGroup display={{label: 'Empty Group'}} />
641+
<CMDKAction display={{label: 'Empty Group'}} />
584642
<CMDKAction display={{label: 'Real Action'}} onAction={jest.fn()} />
585643
<CommandPalette onAction={jest.fn()} />
586644
</CommandPaletteProvider>

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ export function CommandPalette(props: CommandPaletteProps) {
212212

213213
if (action.children.length > 0) {
214214
analytics.recordGroupAction(action, resultIndex);
215+
if ('onAction' in action) {
216+
// Invoke the callback but keep the modal open so users can select
217+
// secondary actions from the children that follow.
218+
props.onAction(action);
219+
}
215220
dispatch({type: 'push action', key: action.key, label: action.display.label});
216221
return;
217222
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) {
2020
navigate(normalizeUrl(String(action.to)));
2121
} else if ('onAction' in action) {
2222
action.onAction();
23+
// When the action has children, the palette will push into them so the
24+
// user can select a secondary action — keep the modal open.
25+
if (action.children.length > 0) {
26+
return;
27+
}
2328
}
2429
closeModal();
2530
},

0 commit comments

Comments
 (0)