Skip to content

Commit 6389b74

Browse files
committed
ref(cmdk) apply limit to query
1 parent dc3e019 commit 6389b74

File tree

4 files changed

+165
-26
lines changed

4 files changed

+165
-26
lines changed

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface DisplayProps {
2020
interface CMDKActionDataBase {
2121
display: DisplayProps;
2222
keywords?: string[];
23+
limit?: number;
2324
ref?: React.RefObject<HTMLElement | null>;
2425
}
2526

@@ -65,7 +66,8 @@ interface CMDKActionProps {
6566
children?: React.ReactNode | ((data: CommandPaletteAction[]) => React.ReactNode);
6667
keywords?: string[];
6768
/**
68-
* Maximum number of results to show when using a resource. Defaults to 4.
69+
* Maximum number of results to show. For async resources the default is 4;
70+
* for static children there is no limit unless this prop is set explicitly.
6971
*/
7072
limit?: number;
7173
onAction?: () => void;
@@ -86,16 +88,20 @@ export function CMDKAction({
8688
to,
8789
onAction,
8890
resource,
89-
limit = 4,
91+
limit,
9092
}: CMDKActionProps) {
9193
const ref = CommandPaletteSlot.useSlotOutletRef();
9294

95+
// For async resources, default to 4 when no explicit limit is given.
96+
// For static children, undefined means no limit.
97+
const effectiveLimit = limit ?? (resource ? 4 : undefined);
98+
9399
const nodeData: CMDKActionData =
94100
to === undefined
95101
? onAction === undefined
96-
? {display, keywords, ref, resource}
97-
: {display, keywords, ref, onAction}
98-
: {display, keywords, ref, to};
102+
? {display, keywords, ref, resource, limit: effectiveLimit}
103+
: {display, keywords, ref, onAction, limit: effectiveLimit}
104+
: {display, keywords, ref, to, limit: effectiveLimit};
99105

100106
const key = CMDKCollection.useRegisterNode(nodeData);
101107
const {query} = useCommandPaletteState();
@@ -113,11 +119,7 @@ export function CMDKAction({
113119
}
114120

115121
const resolvedChildren =
116-
typeof children === 'function'
117-
? data
118-
? children(data.slice(0, limit))
119-
: null
120-
: children;
122+
typeof children === 'function' ? (data ? children(data) : null) : children;
121123

122124
return (
123125
<CMDKCollection.Context.Provider value={key}>

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

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,130 @@ describe('CommandPalette', () => {
439439
expect(screen.queryByRole('option', {name: 'Action 6'})).not.toBeInTheDocument();
440440
});
441441

442+
it('limits static children when limit prop is set', async () => {
443+
render(
444+
<GlobalActionsComponent>
445+
<CMDKAction display={{label: 'Static Group'}} limit={2}>
446+
<CMDKAction display={{label: 'Item 1'}} onAction={jest.fn()} />
447+
<CMDKAction display={{label: 'Item 2'}} onAction={jest.fn()} />
448+
<CMDKAction display={{label: 'Item 3'}} onAction={jest.fn()} />
449+
<CMDKAction display={{label: 'Item 4'}} onAction={jest.fn()} />
450+
</CMDKAction>
451+
</GlobalActionsComponent>
452+
);
453+
454+
await screen.findByRole('option', {name: 'Item 1'});
455+
const actionOptions = screen
456+
.getAllByRole('option')
457+
.filter(el => !el.hasAttribute('aria-disabled'));
458+
expect(actionOptions).toHaveLength(2);
459+
expect(screen.queryByRole('option', {name: 'Item 3'})).not.toBeInTheDocument();
460+
expect(screen.queryByRole('option', {name: 'Item 4'})).not.toBeInTheDocument();
461+
});
462+
463+
it('does not limit static children when limit prop is not set', async () => {
464+
render(
465+
<GlobalActionsComponent>
466+
<CMDKAction display={{label: 'Static Group'}}>
467+
<CMDKAction display={{label: 'Item 1'}} onAction={jest.fn()} />
468+
<CMDKAction display={{label: 'Item 2'}} onAction={jest.fn()} />
469+
<CMDKAction display={{label: 'Item 3'}} onAction={jest.fn()} />
470+
<CMDKAction display={{label: 'Item 4'}} onAction={jest.fn()} />
471+
<CMDKAction display={{label: 'Item 5'}} onAction={jest.fn()} />
472+
</CMDKAction>
473+
</GlobalActionsComponent>
474+
);
475+
476+
await screen.findByRole('option', {name: 'Item 5'});
477+
const actionOptions = screen
478+
.getAllByRole('option')
479+
.filter(el => !el.hasAttribute('aria-disabled'));
480+
expect(actionOptions).toHaveLength(5);
481+
});
482+
483+
it('items beyond the limit are still searchable', async () => {
484+
render(
485+
<GlobalActionsComponent>
486+
<CMDKAction display={{label: 'Static Group'}} limit={2}>
487+
<CMDKAction display={{label: 'Alpha 1'}} onAction={jest.fn()} />
488+
<CMDKAction display={{label: 'Alpha 2'}} onAction={jest.fn()} />
489+
<CMDKAction display={{label: 'Beta 3'}} onAction={jest.fn()} />
490+
<CMDKAction display={{label: 'Beta 4'}} onAction={jest.fn()} />
491+
</CMDKAction>
492+
</GlobalActionsComponent>
493+
);
494+
495+
// Without a query, only the first 2 items should be visible
496+
await screen.findByRole('option', {name: 'Alpha 1'});
497+
expect(screen.queryByRole('option', {name: 'Beta 3'})).not.toBeInTheDocument();
498+
499+
// Searching for "Beta" should surface items 3 and 4 even though they are
500+
// beyond the default limit — the limit must be applied after filtering,
501+
// not before, so it never hides matching results from the user.
502+
const input = screen.getByRole('textbox', {name: 'Search commands'});
503+
await userEvent.type(input, 'Beta');
504+
505+
expect(await screen.findByRole('option', {name: 'Beta 3'})).toBeInTheDocument();
506+
expect(screen.getByRole('option', {name: 'Beta 4'})).toBeInTheDocument();
507+
});
508+
509+
it('limit is applied after search — only top matches up to the limit are shown', async () => {
510+
render(
511+
<GlobalActionsComponent>
512+
<CMDKAction display={{label: 'Static Group'}} limit={2}>
513+
<CMDKAction display={{label: 'Item 1'}} onAction={jest.fn()} />
514+
<CMDKAction display={{label: 'Item 2'}} onAction={jest.fn()} />
515+
<CMDKAction display={{label: 'Item 3'}} onAction={jest.fn()} />
516+
<CMDKAction display={{label: 'Item 4'}} onAction={jest.fn()} />
517+
</CMDKAction>
518+
</GlobalActionsComponent>
519+
);
520+
521+
const input = await screen.findByRole('textbox', {name: 'Search commands'});
522+
await userEvent.type(input, 'Item');
523+
524+
expect(await screen.findByRole('option', {name: 'Item 1'})).toBeInTheDocument();
525+
expect(screen.getByRole('option', {name: 'Item 2'})).toBeInTheDocument();
526+
expect(screen.queryByRole('option', {name: 'Item 3'})).not.toBeInTheDocument();
527+
expect(screen.queryByRole('option', {name: 'Item 4'})).not.toBeInTheDocument();
528+
});
529+
530+
it('limit is applied after search for async resource results', async () => {
531+
const actions = makeActions(6);
532+
533+
render(
534+
<GlobalActionsComponent>
535+
<CMDKAction
536+
display={{label: 'Async Group'}}
537+
limit={2}
538+
resource={() => ({
539+
queryKey: ['test-resource-search-limit'],
540+
queryFn: async () => actions,
541+
})}
542+
>
543+
{data =>
544+
data.map(action =>
545+
'to' in action ? (
546+
<CMDKAction
547+
key={action.display.label}
548+
display={action.display}
549+
to={action.to}
550+
/>
551+
) : null
552+
)
553+
}
554+
</CMDKAction>
555+
</GlobalActionsComponent>
556+
);
557+
558+
const input = await screen.findByRole('textbox', {name: 'Search commands'});
559+
await userEvent.type(input, 'Action');
560+
561+
expect(await screen.findByRole('option', {name: 'Action 1'})).toBeInTheDocument();
562+
expect(screen.getByRole('option', {name: 'Action 2'})).toBeInTheDocument();
563+
expect(screen.queryByRole('option', {name: 'Action 3'})).not.toBeInTheDocument();
564+
});
565+
442566
it('respects a custom limit prop', async () => {
443567
const actions = makeActions(6);
444568

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

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,9 @@ function flattenActions(
479479

480480
results.push({...node, listItemType: isGroup ? 'section' : 'action'});
481481
if (isGroup) {
482-
for (const child of node.children) {
482+
const visibleChildren =
483+
node.limit === undefined ? node.children : node.children.slice(0, node.limit);
484+
for (const child of visibleChildren) {
483485
results.push({...child, listItemType: 'action'});
484486
}
485487
}
@@ -513,32 +515,39 @@ function flattenActions(
513515
return maxScore(b) - maxScore(a);
514516
});
515517

518+
// Track processed keys inline so children beyond a group's limit cannot
519+
// resurface as standalone flat items later in the traversal.
520+
const seen = new Set<string>();
521+
516522
const flattened = collected.flatMap((item): CMDKFlatItem[] => {
523+
if (seen.has(item.key)) return [];
524+
seen.add(item.key);
525+
517526
if (item.children.length > 0) {
518527
const matched = item.children.filter(c => scores.get(c.key)?.score.matched);
519528
if (!matched.length) return [];
529+
const sortedMatches = matched.sort(
530+
(a, b) =>
531+
(scores.get(b.key)?.score.score ?? 0) - (scores.get(a.key)?.score.score ?? 0)
532+
);
533+
const limitedMatches =
534+
item.limit === undefined ? sortedMatches : sortedMatches.slice(0, item.limit);
535+
// Mark every child as seen — including those beyond the limit — so they
536+
// cannot appear as independent flat items after the group is processed.
537+
for (const child of item.children) {
538+
seen.add(child.key);
539+
}
520540
return [
521541
// Suffix the header key so a group used as both a section header and
522542
// an action item inside its parent doesn't produce duplicate React keys.
523543
{...item, key: `${item.key}:header`, listItemType: 'section'},
524-
...matched
525-
.sort(
526-
(a, b) =>
527-
(scores.get(b.key)?.score.score ?? 0) -
528-
(scores.get(a.key)?.score.score ?? 0)
529-
)
530-
.map(c => ({...c, listItemType: 'action' as const})),
544+
...limitedMatches.map(c => ({...c, listItemType: 'action' as const})),
531545
];
532546
}
533547
return scores.get(item.key)?.score.matched ? [{...item, listItemType: 'action'}] : [];
534548
});
535549

536-
const seen = new Set<string>();
537-
return flattened.filter(item => {
538-
if (seen.has(item.key)) return false;
539-
seen.add(item.key);
540-
return true;
541-
});
550+
return flattened;
542551
}
543552

544553
function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem {

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,10 @@ export function GlobalCommandPaletteActions() {
208208
)}
209209
</CMDKAction>
210210

211-
<CMDKAction display={{label: t('Project Settings'), icon: <IconSettings />}}>
211+
<CMDKAction
212+
display={{label: t('Project Settings'), icon: <IconSettings />}}
213+
limit={4}
214+
>
212215
{projects.map(project => (
213216
<CMDKAction
214217
key={project.id}
@@ -287,6 +290,7 @@ export function GlobalCommandPaletteActions() {
287290
<CMDKAction
288291
display={{label: t('Project DSN Keys'), icon: <IconLock locked />}}
289292
keywords={[t('client keys'), t('dsn keys')]}
293+
limit={4}
290294
>
291295
{projects.map(project => (
292296
<CMDKAction
@@ -375,7 +379,7 @@ export function GlobalCommandPaletteActions() {
375379
select: data => {
376380
const results = [];
377381
for (const index of data) {
378-
for (const hit of index.hits.slice(0, 3)) {
382+
for (const hit of index.hits) {
379383
results.push({
380384
display: {
381385
label: DOMPurify.sanitize(hit.title ?? '', {ALLOWED_TAGS: []}),

0 commit comments

Comments
 (0)