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
15 changes: 14 additions & 1 deletion static/app/components/commandPalette/__stories__/components.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
import {useCallback} from 'react';
import type {LocationDescriptorObject} from 'history';

import {addSuccessMessage} from 'sentry/actionCreators/indicator';
import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk';
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 {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
import {useNavigate} from 'sentry/utils/useNavigate';

function locationToString(to: string | LocationDescriptorObject) {
if (typeof to === 'string') {
return to;
}
return [to.pathname, to.search, to.hash].filter(Boolean).join('');
}

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(locationToString(normalizeUrl(action.to)), '_blank');
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
132 changes: 110 additions & 22 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 {
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 @@ type CMDKFlatItem = CollectionTreeNode<CMDKActionData> & {
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 @@ export function CommandPalette(props: CommandPaletteProps) {

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 @@ export function CommandPalette(props: CommandPaletteProps) {
>();
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 @@ export function CommandPalette(props: CommandPaletteProps) {
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 @@ export function CommandPalette(props: CommandPaletteProps) {
<Item<CommandPaletteActionMenuItem>
{...menuItem}
key={action.key}
textValue={action.display.label}
textValue={textValue}
>
{menuItem.label}
</Item>
Expand Down Expand Up @@ -211,25 +220,72 @@ export function CommandPalette(props: CommandPaletteProps) {
const resultIndex = actions.indexOf(action);

if (action.children.length > 0) {
analytics.recordGroupAction(action, resultIndex);
analytics.recordGroupAction(
{
display: {label: getTextValue(action.display)},
...('to' in action ? {to: action.to} : {}),
},
resultIndex
);
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;
}

analytics.recordAction(action, resultIndex, '');
dispatch({type: 'trigger action'});
props.onAction(action);
const modifierKeys: CMDKModifierKeys = {shift: shiftHeldRef.current};

analytics.recordAction(
{
display: {label: getTextValue(action.display)},
...('to' in action ? {to: action.to} : {}),
},
resultIndex,
''
);
// 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 +350,6 @@ export function CommandPalette(props: CommandPaletteProps) {
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 +470,13 @@ function scoreNode(
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 +522,11 @@ function flattenActions(
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 +538,21 @@ function flattenActions(
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 +598,7 @@ function flattenActions(
(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 +613,12 @@ function flattenActions(
});
}

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

function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem {
return {
key: action.key,
Expand All @@ -558,6 +637,15 @@ function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuI
<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