Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
7b1717b
ref(cmdk): implement typed collection factory for JSX-based action re…
JonasBa Apr 6, 2026
39a8156
ref(cmdk): migrate command palette to JSX collection model (steps 2–5)
JonasBa Apr 6, 2026
c06aae5
ref(cmdk): pre-review cleanup
JonasBa Apr 6, 2026
a009877
remove redundant context
JonasBa Apr 6, 2026
b821641
ref(cmdk): remove useGlobalCommandPaletteActions
JonasBa Apr 6, 2026
a8b1229
ref(cmdk): delete context.tsx, move CommandPaletteProvider to cmdk.tsx
JonasBa Apr 6, 2026
dbca928
ref(cmdk): move GlobalCommandPaletteActions into modal
JonasBa Apr 6, 2026
c1b7b0e
ref(cmdk): CommandPalette accepts children for action registration
JonasBa Apr 6, 2026
a5330eb
fix(cmdk): pass LocationDescriptor directly instead of stringifying
JonasBa Apr 6, 2026
12c8da6
fix(cmdk): guard nav sidebar toggle against missing SecondaryNavigati…
JonasBa Apr 6, 2026
5585fc2
fix(cmdk): restore GlobalCommandPaletteActions to navigation scope
JonasBa Apr 6, 2026
a2107d7
ref(cmdk) add slot context
JonasBa Apr 6, 2026
1583240
ref(cmdk) add issues list actions
JonasBa Apr 6, 2026
ddcc461
test(cmdk): Add failing test for slot rendering priority and fix miss…
JonasBa Apr 6, 2026
d4e45f2
docs(cmdk): Add implementation plan for slot-priority pre-sort
JonasBa Apr 6, 2026
162d409
feat(cmdk): Sort actions by slot priority using outlet DOM position
JonasBa Apr 6, 2026
406e56e
feat(cmdk): Wire slot priority sorting into collection and palette
JonasBa Apr 6, 2026
d97b598
ref(cmdk) wip
JonasBa Apr 6, 2026
562a106
ref(cmdk) cleanup
JonasBa Apr 6, 2026
05468ef
ref(cmdk) implement proper actions
JonasBa Apr 6, 2026
eef1471
fix(cmdk): Gate DSN lookup query behind DSN_PATTERN check
JonasBa Apr 6, 2026
dce75f9
Merge origin/master into jb/cmdk/jsx-poc
JonasBa Apr 6, 2026
0158ce5
ref(cmdk) revert issues list poc
JonasBa Apr 6, 2026
4406dc2
docs(cmdk): Update story to JSX-powered command palette API
JonasBa Apr 7, 2026
9a0a7c6
ref(cmdk): Merge CMDKGroup and CMDKAction into single CMDKAction comp…
JonasBa Apr 9, 2026
9d70bc8
fix(cmdk): Omit empty groups and reset scroll on query change (#112325)
JonasBa Apr 9, 2026
497dd7e
feat(cmdk): Invoke onAction callback for actions with children
JonasBa Apr 9, 2026
e2b0710
fix(cmdk): Use render prop closeModal to properly reset open state
JonasBa Apr 9, 2026
dd0b8b9
perf(cmdk): Score each candidate field individually in scoreNode
JonasBa Apr 9, 2026
f1a1a06
fix(cmdk): Address bugbot findings — admin actions, DSN icon, String …
JonasBa Apr 9, 2026
c56f2d2
fix(cmdk): Remove String() coercion in story component
JonasBa Apr 9, 2026
7508757
fix(slot): Render nothing when no outlet is registered (#112568)
JonasBa Apr 9, 2026
9b83079
Merge branch 'master' into jb/cmdk/jsx-poc
JonasBa Apr 9, 2026
f473658
Merge branch 'master' into jb/cmdk/jsx-poc
JonasBa Apr 9, 2026
fa3d675
remove unused exports
JonasBa Apr 9, 2026
e198a91
ref(cmdk): Remove ActionsToJSX adapter and CommandPaletteAsyncResult …
JonasBa Apr 9, 2026
cd2aa33
ref(cmdk) move admin actions
JonasBa Apr 9, 2026
71408f1
fix(cmdk): Filter out async resource actions with 0 results
JonasBa Apr 9, 2026
2e92f67
ref(cmdk) simplify test
JonasBa Apr 10, 2026
369acc0
fix(cmdk): Filter empty resource nodes from group children in search …
JonasBa Apr 10, 2026
967c143
Merge branch 'master' into jb/cmdk/empty-async-groups
JonasBa Apr 13, 2026
600ad43
fix(cmdk): Exclude prompt+resource nodes from empty resource filtering
JonasBa Apr 13, 2026
9c5f53a
ref(cmdk) remove mock and global render
JonasBa Apr 13, 2026
9b9be37
fix(cmdk): Omit section header when all group children are empty reso…
JonasBa Apr 13, 2026
d3ec8a1
Merge branch 'master' into jb/cmdk/empty-async-groups
JonasBa Apr 13, 2026
56ee31a
ref(cmdk) use cmdkQueryOptions
JonasBa Apr 13, 2026
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
137 changes: 137 additions & 0 deletions static/app/components/commandPalette/ui/commandPalette.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jest.mock('@tanstack/react-virtual', () => ({

import {closeModal} from 'sentry/actionCreators/modal';
import * as modalActions from 'sentry/actionCreators/modal';
import type {CommandPaletteAction} from 'sentry/components/commandPalette/types';
import {cmdkQueryOptions} from 'sentry/components/commandPalette/types';
import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk';
import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk';
Expand Down Expand Up @@ -834,4 +835,140 @@ describe('CommandPalette', () => {
expect(screen.getAllByRole('option')).toHaveLength(1);
});
});

describe('resource action with 0 results', () => {
function emptyResource() {
return cmdkQueryOptions({
queryKey: ['test-empty-resource'] as const,
queryFn: (): CommandPaletteAction[] => [],
});
}

it('is omitted from browse mode at the top level', async () => {
render(
<CommandPaletteProvider>
<CMDKAction display={{label: 'Async Resource'}} resource={emptyResource}>
{data =>
data.map((_, i) => (
<CMDKAction key={i} to="/x/" display={{label: 'Result'}} />
))
}
</CMDKAction>
<CMDKAction display={{label: 'Real Action'}} onAction={jest.fn()} />
<CommandPalette onAction={jest.fn()} />
</CommandPaletteProvider>
);

await screen.findByRole('option', {name: 'Real Action'});
expect(
screen.queryByRole('option', {name: 'Async Resource'})
).not.toBeInTheDocument();
});

it('is omitted from browse mode when nested inside a group', async () => {
render(
<CommandPaletteProvider>
<CMDKAction display={{label: 'Group'}}>
<CMDKAction display={{label: 'Async Resource'}} resource={emptyResource}>
{data =>
data.map((_, i) => (
<CMDKAction key={i} to="/x/" display={{label: 'Result'}} />
))
}
</CMDKAction>
<CMDKAction display={{label: 'Real Action'}} onAction={jest.fn()} />
</CMDKAction>
<CommandPalette onAction={jest.fn()} />
</CommandPaletteProvider>
);

await screen.findByRole('option', {name: 'Real Action'});
expect(
screen.queryByRole('option', {name: 'Async Resource'})
).not.toBeInTheDocument();
});

it('group whose only children are empty resource nodes is omitted entirely in browse mode', async () => {
// Regression: browse mode pushed the section header unconditionally before
// iterating children. If every child was an empty resource node and got
// skipped, an orphaned, non-selectable section header was left in the list.
render(
<CommandPaletteProvider>
<CMDKAction display={{label: 'All Empty Group'}}>
<CMDKAction display={{label: 'Async Resource'}} resource={emptyResource}>
{data =>
data.map((_, i) => (
<CMDKAction key={i} to="/x/" display={{label: 'Result'}} />
))
}
</CMDKAction>
</CMDKAction>
<CMDKAction display={{label: 'Real Action'}} onAction={jest.fn()} />
<CommandPalette onAction={jest.fn()} />
</CommandPaletteProvider>
);

await screen.findByRole('option', {name: 'Real Action'});
// Neither the section header nor any item for the all-empty group should appear.
expect(
screen.queryByRole('option', {name: 'All Empty Group'})
).not.toBeInTheDocument();
});

it('is omitted from search mode when nested inside a group whose label matches the query', async () => {
render(
<CommandPaletteProvider>
<CMDKAction display={{label: 'Navigate'}}>
<CMDKAction display={{label: 'Async Resource'}} resource={emptyResource}>
{data =>
data.map((_, i) => (
<CMDKAction key={i} to="/x/" display={{label: 'Result'}} />
))
}
</CMDKAction>
</CMDKAction>
<CMDKAction display={{label: 'Other'}} onAction={jest.fn()} />
<CommandPalette onAction={jest.fn()} />
</CommandPaletteProvider>
);

const input = await screen.findByRole('textbox', {name: 'Search commands'});
await userEvent.type(input, 'navigate');

// Wait for search to take effect — 'Other' should be filtered out since it doesn't match
await waitFor(() => {
expect(screen.queryByRole('option', {name: 'Other'})).not.toBeInTheDocument();
});
expect(
screen.queryByRole('option', {name: 'Async Resource'})
).not.toBeInTheDocument();
});

it('is omitted from search mode even when the label matches the query', async () => {
render(
<CommandPaletteProvider>
<CMDKAction display={{label: 'Async Resource'}} resource={emptyResource}>
{data =>
data.map((_, i) => (
<CMDKAction key={i} to="/x/" display={{label: 'Result'}} />
))
}
</CMDKAction>
<CMDKAction display={{label: 'Other'}} onAction={jest.fn()} />
<CommandPalette onAction={jest.fn()} />
</CommandPaletteProvider>
);

const input = await screen.findByRole('textbox', {name: 'Search commands'});
await userEvent.type(input, 'async');

// Wait for search to take effect — 'Other' should be filtered out since it doesn't match
await waitFor(() => {
expect(screen.queryByRole('option', {name: 'Other'})).not.toBeInTheDocument();
});
expect(
screen.queryByRole('option', {name: 'Async Resource'})
).not.toBeInTheDocument();
});
});
});
33 changes: 28 additions & 5 deletions static/app/components/commandPalette/ui/commandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,16 +517,22 @@ function flattenActions(
if (!isGroup && !('to' in node) && !('onAction' in node)) {
const hasPromptOrResource =
('prompt' in node && !!node.prompt) || ('resource' in node && !!node.resource);
if (!hasPromptOrResource) {
if (!hasPromptOrResource || isEmptyResourceNode(node)) {
continue;
}
}

results.push({...node, listItemType: isGroup ? 'section' : 'action'});
if (isGroup) {
for (const child of node.children) {
results.push({...child, listItemType: 'action'});
const children = node.children
.filter(child => !isEmptyResourceNode(child))
.map(child => ({...child, listItemType: 'action' as const}));
if (!children.length) {
continue;
}
results.push({...node, listItemType: 'section'});
results.push(...children);
} else {
results.push({...node, listItemType: 'action'});
}
}
return results;
Expand Down Expand Up @@ -560,7 +566,9 @@ function flattenActions(

const flattened = collected.flatMap((item): CMDKFlatItem[] => {
if (item.children.length > 0) {
const matched = item.children.filter(c => scores.get(c.key)?.score.matched);
const matched = item.children.filter(
c => scores.get(c.key)?.score.matched && !isEmptyResourceNode(c)
);
if (!matched.length) return [];
return [
// Suffix the header key so a group used as both a section header and
Expand All @@ -575,6 +583,11 @@ function flattenActions(
.map(c => ({...c, listItemType: 'action' as const})),
];
}
// Skip resource nodes with no children — they are async group containers that
Comment thread
cursor[bot] marked this conversation as resolved.
// returned 0 results and have no executable action of their own.
if (isEmptyResourceNode(item)) {
return [];
}
return scores.get(item.key)?.score.matched ? [{...item, listItemType: 'action'}] : [];
});

Expand All @@ -586,6 +599,16 @@ function flattenActions(
});
}

function isEmptyResourceNode(node: CollectionTreeNode<CMDKActionData>): boolean {
return (
node.children.length === 0 &&
'resource' in node &&
!('to' in node) &&
!('onAction' in node) &&
!('prompt' in node && node.prompt)
);
}

function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem {
const isExternal = 'to' in action ? isExternalLocation(action.to) : false;
const trailingItems =
Expand Down
27 changes: 14 additions & 13 deletions static/app/components/commandPalette/useCommandPaletteActions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,20 @@ function YourComponent() {
/>
</CMDKAction>

{/* The command palette UI — also accepts inline actions via children */}
<CommandPalette onAction={handleAction}>
<CMDKAction display={{label: 'Issues List'}}>
<CMDKAction
display={{label: 'Select all'}}
onAction={() => addSuccessMessage('Select all')}
/>
<CMDKAction
display={{label: 'Deselect all'}}
onAction={() => addSuccessMessage('Deselect all')}
/>
</CMDKAction>
</CommandPalette>
{/* Inline actions declared as siblings to the palette */}
<CMDKAction display={{label: 'Issues List'}}>
<CMDKAction
display={{label: 'Select all'}}
onAction={() => addSuccessMessage('Select all')}
/>
<CMDKAction
display={{label: 'Deselect all'}}
onAction={() => addSuccessMessage('Deselect all')}
/>
</CMDKAction>

{/* The command palette UI */}
<CommandPalette onAction={handleAction} />
</CommandPaletteProvider>
);
}
Expand Down
Loading