Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,20 @@
import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk';
import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection';
import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette';
import type {CMDKModifierKeys} from 'sentry/components/commandPalette/ui/commandPalette';
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
import {useNavigate} from 'sentry/utils/useNavigate';

export function CommandPaletteDemo() {
const navigate = useNavigate();

const handleAction = useCallback(
(action: CollectionTreeNode<CMDKActionData>) => {
(action: CollectionTreeNode<CMDKActionData>, modifierKeys: CMDKModifierKeys) => {
if ('to' in action) {
if (modifierKeys.shift) {
window.open(String(normalizeUrl(action.to)), '_blank');

Check failure on line 20 in static/app/components/commandPalette/__stories__/components.tsx

View workflow job for this annotation

GitHub Actions / pre-commit lint

'normalizeUrl(action.to)' may use Object's default stringification format ('[object Object]') when stringified

Check failure on line 20 in static/app/components/commandPalette/__stories__/components.tsx

View workflow job for this annotation

GitHub Actions / eslint

'normalizeUrl(action.to)' may use Object's default stringification format ('[object Object]') when stringified
return;
}
navigate(normalizeUrl(action.to));
} else if ('onAction' in action) {
action.onAction();
Expand Down
31 changes: 24 additions & 7 deletions static/app/components/commandPalette/ui/cmdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,28 @@ import {
useCommandPaletteState,
} from './commandPaletteStateContext';

interface DisplayProps {
label: string;
details?: string;
type DisplayProps = {
icon?: React.ReactNode;
}
} & (
| {
label: string;
details?: string;
searchableDetails?: never;
searchableLabel?: never;
}
| {
label: Exclude<React.ReactNode, string>;
searchableLabel: string;
details?: React.ReactNode;
searchableDetails?: string;
}
);

interface CMDKActionDataBase {
display: DisplayProps;
keywords?: string[];
/** Maximum number of children to show in browse and search mode. */
limit?: number;
ref?: React.RefObject<HTMLElement | null>;
}

Expand Down Expand Up @@ -66,6 +79,8 @@ interface CMDKActionProps {
display: DisplayProps;
children?: React.ReactNode | ((data: CommandPaletteAsyncResult[]) => React.ReactNode);
keywords?: string[];
/** Maximum number of children to show in browse and search mode. */
limit?: number;
onAction?: () => void;
resource?: (query: string) => CMDKQueryOptions;
to?: LocationDescriptor;
Expand All @@ -80,6 +95,7 @@ interface CMDKActionProps {
export function CMDKAction({
display,
keywords,
limit,
children,
to,
onAction,
Expand All @@ -90,11 +106,12 @@ export function CMDKAction({
const nodeData: CMDKActionData =
to === undefined
? onAction === undefined
? {display, keywords, ref, resource}
: {display, keywords, ref, onAction}
: {display, keywords, ref, to};
? {display, keywords, limit, ref, resource}
: {display, keywords, limit, ref, onAction}
: {display, keywords, limit, ref, to};

const key = CMDKCollection.useRegisterNode(nodeData);

const {query} = useCommandPaletteState();

const resourceOptions = resource
Expand Down
1 change: 1 addition & 0 deletions static/app/components/commandPalette/ui/collection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export function makeCollection<T>(): CollectionInstance<T> {
return () => {
store.unregister(key);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, parentKey, store]);

return key;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk';
import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk';
import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection';
import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette';
import type {CMDKModifierKeys} from 'sentry/components/commandPalette/ui/commandPalette';
import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot';
import {useNavigate} from 'sentry/utils/useNavigate';

Expand Down Expand Up @@ -84,7 +85,7 @@ function GlobalActionsComponent({
const navigate = useNavigate();

const handleAction = useCallback(
(action: CollectionTreeNode<CMDKActionData>) => {
(action: CollectionTreeNode<CMDKActionData>, _modifierKeys: CMDKModifierKeys) => {
if ('to' in action) {
navigate(action.to);
} else if ('onAction' in action) {
Expand Down Expand Up @@ -372,7 +373,10 @@ describe('CommandPalette', () => {

// Mirror the updated modal.tsx handleSelect: invoke callback, skip close when
// action has children so the palette can push into the secondary actions.
const handleAction = (action: CollectionTreeNode<CMDKActionData>) => {
const handleAction = (
action: CollectionTreeNode<CMDKActionData>,
_modifierKeys: CMDKModifierKeys
) => {
if ('onAction' in action) {
action.onAction();
if (action.children.length > 0) {
Expand Down
113 changes: 93 additions & 20 deletions static/app/components/commandPalette/ui/commandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Fragment, useCallback, useLayoutEffect, useMemo, useRef} from 'react';
import {Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react';
import {preload} from 'react-dom';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';
Expand Down Expand Up @@ -30,7 +30,7 @@
import {useCommandPaletteAnalytics} from 'sentry/components/commandPalette/useCommandPaletteAnalytics';
import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {IconArrow, IconClose, IconSearch} from 'sentry/icons';
import {IconArrow, IconClose, IconLink, IconOpen, IconSearch} from 'sentry/icons';
import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults';
import {t} from 'sentry/locale';
import {fzf} from 'sentry/utils/search/fzf';
Expand Down Expand Up @@ -64,8 +64,15 @@
listItemType: 'action' | 'section';
};

export interface CMDKModifierKeys {
shift: boolean;
}

interface CommandPaletteProps {
onAction: (action: CollectionTreeNode<CMDKActionData>) => void;
onAction: (
action: CollectionTreeNode<CMDKActionData>,
modifierKeys: CMDKModifierKeys
) => void;
children?: React.ReactNode;
}

Expand All @@ -90,7 +97,7 @@

const actions = useMemo<CMDKFlatItem[]>(() => {
if (!state.query) {
return flattenActions(currentNodes, null);
return flattenActions(currentNodes, null, !state.action);
}

const scores = new Map<
Expand All @@ -99,7 +106,7 @@
>();
scoreTree(currentNodes, scores, state.query.toLowerCase());
return flattenActions(currentNodes, scores);
}, [currentNodes, state.query]);
}, [currentNodes, state.query, state.action]);

const analytics = useCommandPaletteAnalytics(actions.length);

Expand All @@ -116,12 +123,14 @@
children: actions.map(action => {
const menuItem = makeMenuItemFromAction(action);

const textValue = getTextValue(action.display);

if (action.listItemType === 'section') {
return (
<Item<CommandPaletteActionMenuItem & {hideCheck: boolean; label: string}>
{...menuItem}
key={action.key}
textValue={action.display.label}
textValue={textValue}
{...{
leadingItems: null,
label: (
Expand Down Expand Up @@ -152,7 +161,7 @@
<Item<CommandPaletteActionMenuItem>
{...menuItem}
key={action.key}
textValue={action.display.label}
textValue={textValue}
>
{menuItem.label}
</Item>
Expand Down Expand Up @@ -211,25 +220,59 @@
const resultIndex = actions.indexOf(action);

if (action.children.length > 0) {
analytics.recordGroupAction(action, resultIndex);

Check failure on line 223 in static/app/components/commandPalette/ui/commandPalette.tsx

View workflow job for this annotation

GitHub Actions / @typescript/native-preview

Argument of type 'CMDKFlatItem' is not assignable to parameter of type 'ActionLike'.

Check failure on line 223 in static/app/components/commandPalette/ui/commandPalette.tsx

View workflow job for this annotation

GitHub Actions / typescript

Argument of type 'CMDKFlatItem' is not assignable to parameter of type 'ActionLike'.
if ('onAction' in action) {
// Invoke the callback but keep the modal open so users can select
// secondary actions from the children that follow.
props.onAction(action);
props.onAction(action, {shift: false});
}
dispatch({type: 'push action', key: action.key, label: action.display.label});
dispatch({
type: 'push action',
key: action.key,
label: getTextValue(action.display),
});
return;
}

const modifierKeys: CMDKModifierKeys = {shift: shiftHeldRef.current};

analytics.recordAction(action, resultIndex, '');

Check failure on line 239 in static/app/components/commandPalette/ui/commandPalette.tsx

View workflow job for this annotation

GitHub Actions / @typescript/native-preview

Argument of type 'CMDKFlatItem' is not assignable to parameter of type 'ActionLike'.

Check failure on line 239 in static/app/components/commandPalette/ui/commandPalette.tsx

View workflow job for this annotation

GitHub Actions / typescript

Argument of type 'CMDKFlatItem' is not assignable to parameter of type 'ActionLike'.
dispatch({type: 'trigger action'});
props.onAction(action);
// When Shift is held and the action is a navigation link, keep the palette
// open (no trigger action dispatch) so the user can continue selecting.
if (!modifierKeys.shift || !('to' in action)) {
dispatch({type: 'trigger action'});
}
props.onAction(action, modifierKeys);
},
[actions, analytics, dispatch, props]
);

const resultsListRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (resultsListRef.current) {
resultsListRef.current.scrollTop = 0;
}
}, [state.action, state.query]);

// Track whether Shift is held so that actions with `to` can be opened in a
// new tab. Using a ref (instead of state) avoids re-renders on every keypress.
const shiftHeldRef = useRef(false);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftHeldRef.current = true;
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftHeldRef.current = false;
};
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('keyup', onKeyUp);
};
}, []);

const debouncedQuery = useDebouncedValue(state.query, 300);

const isLoading = state.query.length > 0 && debouncedQuery !== state.query;
Expand Down Expand Up @@ -294,9 +337,6 @@
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({type: 'set query', query: e.target.value});
treeState.selectionManager.setFocusedKey(null);
if (resultsListRef.current) {
resultsListRef.current.scrollTop = 0;
}
},
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && state.query.length === 0) {
Expand Down Expand Up @@ -417,8 +457,13 @@
query: string,
node: CollectionTreeNode<CMDKActionData>
): {matched: boolean; score: number} {
const label = node.display.label;
const details = node.display.details ?? '';
const display = node.display;
const label =
typeof display.label === 'string' ? display.label : display.searchableLabel;
const details =
typeof display.details === 'string'
? display.details
: (display.searchableDetails ?? '');
const keywords = node.keywords ?? [];

// Score each field independently and take the best result. This lets
Expand Down Expand Up @@ -464,7 +509,11 @@
scores: Map<
string,
{node: CollectionTreeNode<CMDKActionData>; score: {matched: boolean; score: number}}
> | null
> | null,
// Only expand groups inline at the true root level. When the user has
// navigated into a group, nested groups should appear as navigable actions
// rather than being pre-expanded as section headers with their children.
expandGroups = true
): CMDKFlatItem[] {
// Browse mode: show each top-level node and its direct children.
if (!scores) {
Expand All @@ -476,11 +525,21 @@
if (!isGroup && !('to' in node) && !('onAction' in node)) {
continue;
}
results.push({...node, listItemType: isGroup ? 'section' : 'action'});
if (isGroup) {
for (const child of node.children) {
if (isGroup && expandGroups) {
// Expand the group inline: render it as a section header followed by
// its direct children. This only happens at the true root level so
// that nested groups (e.g. "Set Priority" inside "Select All") are
// shown as navigable actions rather than pre-expanded sections.
results.push({...node, listItemType: 'section'});
for (const child of node.limit === undefined
? node.children
: node.children.slice(0, node.limit)) {
results.push({...child, listItemType: 'action'});
}
} else {
// Leaf action or a group that should not be expanded inline — treat
// as a single navigable item regardless.
results.push({...node, listItemType: 'action'});
}
}
return results;
Expand Down Expand Up @@ -526,6 +585,7 @@
(scores.get(b.key)?.score.score ?? 0) -
(scores.get(a.key)?.score.score ?? 0)
)
.slice(0, item.limit)
.map(c => ({...c, listItemType: 'action' as const})),
];
}
Expand All @@ -540,6 +600,10 @@
});
}

function getTextValue(display: CMDKActionData['display']): string {
return typeof display.label === 'string' ? display.label : display.searchableLabel;

Check failure on line 604 in static/app/components/commandPalette/ui/commandPalette.tsx

View workflow job for this annotation

GitHub Actions / @typescript/native-preview

Type 'string | undefined' is not assignable to type 'string'.

Check failure on line 604 in static/app/components/commandPalette/ui/commandPalette.tsx

View workflow job for this annotation

GitHub Actions / typescript

Type 'string | undefined' is not assignable to type 'string'.
}

function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem {
return {
key: action.key,
Expand All @@ -558,6 +622,15 @@
<IconDefaultsProvider size="sm">{action.display.icon}</IconDefaultsProvider>
</Flex>
),
trailingItems:
'to' in action ? (
typeof action.to === 'string' &&
(action.to.startsWith('http://') || action.to.startsWith('https://')) ? (
<IconOpen size="xs" variant="muted" />
) : (
<IconLink size="xs" variant="muted" />
)
) : undefined,
children: [],
hideCheck: true,
};
Expand Down
Loading
Loading