Skip to content

Commit 4496011

Browse files
committed
ref(issues) cmdk actions
1 parent c56f2d2 commit 4496011

6 files changed

Lines changed: 224 additions & 118 deletions

File tree

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export function CommandPalette(props: CommandPaletteProps) {
9090

9191
const actions = useMemo<CMDKFlatItem[]>(() => {
9292
if (!state.query) {
93-
return flattenActions(currentNodes, null);
93+
return flattenActions(currentNodes, null, !state.action);
9494
}
9595

9696
const scores = new Map<
@@ -99,7 +99,7 @@ export function CommandPalette(props: CommandPaletteProps) {
9999
>();
100100
scoreTree(currentNodes, scores, state.query.toLowerCase());
101101
return flattenActions(currentNodes, scores);
102-
}, [currentNodes, state.query]);
102+
}, [currentNodes, state.query, state.action]);
103103

104104
const analytics = useCommandPaletteAnalytics(actions.length);
105105

@@ -459,12 +459,21 @@ function scoreTree(
459459
}
460460
}
461461

462+
// Maximum number of children to show per group, in both browse and search mode.
463+
// Prevents groups with many items (e.g. per-project settings on large orgs)
464+
// from flooding the results list.
465+
const MAX_GROUP_CHILDREN = 4;
466+
462467
function flattenActions(
463468
nodes: Array<CollectionTreeNode<CMDKActionData>>,
464469
scores: Map<
465470
string,
466471
{node: CollectionTreeNode<CMDKActionData>; score: {matched: boolean; score: number}}
467-
> | null
472+
> | null,
473+
// Only expand groups inline at the true root level. When the user has
474+
// navigated into a group, nested groups should appear as navigable actions
475+
// rather than being pre-expanded as section headers with their children.
476+
expandGroups = true
468477
): CMDKFlatItem[] {
469478
// Browse mode: show each top-level node and its direct children.
470479
if (!scores) {
@@ -476,11 +485,19 @@ function flattenActions(
476485
if (!isGroup && !('to' in node) && !('onAction' in node)) {
477486
continue;
478487
}
479-
results.push({...node, listItemType: isGroup ? 'section' : 'action'});
480-
if (isGroup) {
481-
for (const child of node.children) {
488+
if (isGroup && expandGroups) {
489+
// Expand the group inline: render it as a section header followed by
490+
// its direct children. This only happens at the true root level so
491+
// that nested groups (e.g. "Set Priority" inside "Select All") are
492+
// shown as navigable actions rather than pre-expanded sections.
493+
results.push({...node, listItemType: 'section'});
494+
for (const child of node.children.slice(0, MAX_GROUP_CHILDREN)) {
482495
results.push({...child, listItemType: 'action'});
483496
}
497+
} else {
498+
// Leaf action or a group that should not be expanded inline — treat
499+
// as a single navigable item regardless.
500+
results.push({...node, listItemType: 'action'});
484501
}
485502
}
486503
return results;
@@ -526,6 +543,7 @@ function flattenActions(
526543
(scores.get(b.key)?.score.score ?? 0) -
527544
(scores.get(a.key)?.score.score ?? 0)
528545
)
546+
.slice(0, MAX_GROUP_CHILDREN)
529547
.map(c => ({...c, listItemType: 'action' as const})),
530548
];
531549
}

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

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,44 @@ export function GlobalCommandPaletteActions() {
220220
/>
221221
))}
222222
</CMDKAction>
223+
224+
{user.isStaff && (
225+
<CMDKAction display={{label: t('Admin')}}>
226+
<CMDKAction
227+
display={{label: t('Open _admin'), icon: <IconOpen />}}
228+
keywords={[t('superuser')]}
229+
onAction={() => window.open('/_admin/', '_blank', 'noreferrer')}
230+
/>
231+
<CMDKAction
232+
display={{
233+
label: t('Open %s in _admin', organization.name),
234+
icon: <IconOpen />,
235+
}}
236+
keywords={[t('superuser')]}
237+
onAction={() =>
238+
window.open(
239+
`/_admin/customers/${organization.slug}/`,
240+
'_blank',
241+
'noreferrer'
242+
)
243+
}
244+
/>
245+
{!isActiveSuperuser() && (
246+
<CMDKAction
247+
display={{label: t('Open Superuser Modal'), icon: <IconLock locked />}}
248+
keywords={[t('superuser')]}
249+
onAction={() => openSudo({isSuperuser: true, needsReload: true})}
250+
/>
251+
)}
252+
{isActiveSuperuser() && (
253+
<CMDKAction
254+
display={{label: t('Exit Superuser'), icon: <IconLock locked={false} />}}
255+
keywords={[t('superuser')]}
256+
onAction={() => exitSuperuser()}
257+
/>
258+
)}
259+
</CMDKAction>
260+
)}
223261
</CMDKAction>
224262

225263
<CMDKAction display={{label: t('Add')}}>
@@ -395,44 +433,6 @@ export function GlobalCommandPaletteActions() {
395433
/>
396434
</CMDKAction>
397435
</CMDKAction>
398-
399-
{user.isStaff && (
400-
<CMDKAction display={{label: t('Admin')}}>
401-
<CMDKAction
402-
display={{label: t('Open _admin'), icon: <IconOpen />}}
403-
keywords={[t('superuser')]}
404-
onAction={() => window.open('/_admin/', '_blank', 'noreferrer')}
405-
/>
406-
<CMDKAction
407-
display={{
408-
label: t('Open %s in _admin', organization.name),
409-
icon: <IconOpen />,
410-
}}
411-
keywords={[t('superuser')]}
412-
onAction={() =>
413-
window.open(
414-
`/_admin/customers/${organization.slug}/`,
415-
'_blank',
416-
'noreferrer'
417-
)
418-
}
419-
/>
420-
{!isActiveSuperuser() && (
421-
<CMDKAction
422-
display={{label: t('Open Superuser Modal'), icon: <IconLock locked />}}
423-
keywords={[t('superuser')]}
424-
onAction={() => openSudo({isSuperuser: true, needsReload: true})}
425-
/>
426-
)}
427-
{isActiveSuperuser() && (
428-
<CMDKAction
429-
display={{label: t('Exit Superuser'), icon: <IconLock locked={false} />}}
430-
keywords={[t('superuser')]}
431-
onAction={() => exitSuperuser()}
432-
/>
433-
)}
434-
</CMDKAction>
435-
)}
436436
</CommandPaletteSlot>
437437
);
438438
}

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,20 @@ function commandPaletteReducer(
4646
): CommandPaletteState {
4747
const type = action.type;
4848
switch (type) {
49-
case 'toggle modal':
49+
case 'toggle modal': {
50+
const nextOpen = !state.open;
5051
return {
5152
...state,
52-
open: !state.open,
53+
open: nextOpen,
54+
// Reset navigation and query when opening. The slot system portals
55+
// CMDKAction children into the outlet's DOM element, so switching
56+
// between portal and in-place rendering causes React to remount those
57+
// components with fresh useId() keys. Any nav-stack key stored from a
58+
// prior session is therefore stale — resetting here ensures the palette
59+
// always opens at the root.
60+
...(nextOpen ? {action: null, query: ''} : {}),
5361
};
62+
}
5463
case 'reset':
5564
return {
5665
...state,

static/app/components/core/slot/slot.spec.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ describe('slot', () => {
1111
expect(SlotModule.Fallback).toBeDefined();
1212
});
1313

14-
it('renders children in place when no Outlet is registered', () => {
14+
it('hides children in a detached container when no Outlet is registered', () => {
15+
// Children are always portaled — to a stable hidden div before the outlet
16+
// mounts, to the real outlet element once it registers. This prevents the
17+
// in-place → portal type switch that would remount the subtree and lose
18+
// component state (e.g. useId() keys). Content is intentionally invisible
19+
// until an outlet is available.
1520
const SlotModule = slot(['header'] as const);
1621

1722
render(
@@ -22,7 +27,7 @@ describe('slot', () => {
2227
</SlotModule.Provider>
2328
);
2429

25-
expect(screen.getByText('inline content')).toBeInTheDocument();
30+
expect(screen.queryByText('inline content')).not.toBeInTheDocument();
2631
});
2732

2833
it('portals children to the Outlet element', () => {
@@ -44,7 +49,7 @@ describe('slot', () => {
4449
);
4550
});
4651

47-
it('multiple slot consumers render their children independently', () => {
52+
it('multiple slot consumers hide their children independently when no Outlet is registered', () => {
4853
const SlotModule = slot(['a', 'b'] as const);
4954

5055
render(
@@ -58,8 +63,8 @@ describe('slot', () => {
5863
</SlotModule.Provider>
5964
);
6065

61-
expect(screen.getByText('slot a content')).toBeInTheDocument();
62-
expect(screen.getByText('slot b content')).toBeInTheDocument();
66+
expect(screen.queryByText('slot a content')).not.toBeInTheDocument();
67+
expect(screen.queryByText('slot b content')).not.toBeInTheDocument();
6368
});
6469

6570
it('consumer throws when rendered outside provider', () => {

static/app/components/core/slot/slot.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,6 @@ function makeSlotConsumer<T extends Slot>(
140140
return () => dispatch({type: 'decrement counter', name});
141141
}, [dispatch, name]);
142142

143-
const element = state[name]?.element;
144-
145143
// Provide outletNameContext from the consumer so that portaled children
146144
// (which don't descend through the outlet in the component tree) can still
147145
// read which slot they belong to via useSlotOutletRef.
@@ -151,11 +149,27 @@ function makeSlotConsumer<T extends Slot>(
151149
</outletNameContext.Provider>
152150
);
153151

154-
if (!element) {
155-
// Render in place as a fallback when no target element is registered yet
156-
return wrappedChildren;
152+
// A stable hidden container used as the portal target before an outlet
153+
// mounts. Keeping children always inside a portal (rather than switching
154+
// between portal and in-place rendering) means React never sees a
155+
// different element type across renders, so component instances — and
156+
// the ids they hold (e.g. useId()) — are preserved when the outlet mounts
157+
// or unmounts. This also prevents a flash of content at the wrong DOM
158+
// position that would occur with in-place rendering followed by a teleport.
159+
const hiddenContainer = useRef<HTMLElement | null>(null);
160+
if (!hiddenContainer.current && typeof document !== 'undefined') {
161+
hiddenContainer.current = document.createElement('div');
162+
}
163+
164+
const element = state[name]?.element;
165+
const target = element ?? hiddenContainer.current;
166+
167+
if (!target) {
168+
// SSR: document is not available, render nothing.
169+
return null;
157170
}
158-
return createPortal(wrappedChildren, element);
171+
172+
return createPortal(wrappedChildren, target);
159173
}
160174

161175
SlotConsumer.displayName = 'Slot.Consumer';

0 commit comments

Comments
 (0)