Skip to content
Open
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
10 changes: 9 additions & 1 deletion static/app/actionCreators/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,17 @@ export async function openEditOwnershipRules(options: EditOwnershipRulesModalOpt
});
}

export async function openCommandPaletteDeprecated(options: ModalOptions = {}) {
const {default: Modal, modalCss} = await import(
'sentry/components/modals/deprecatedCommandPalette'
);

openModal(deps => <Modal {...deps} {...options} />, {modalCss});
}

export async function openCommandPalette(options: ModalOptions = {}) {
const {default: Modal, modalCss} = await import(
'sentry/components/modals/commandPalette'
'sentry/components/commandPalette/ui/modal'
);

openModal(deps => <Modal {...deps} {...options} />, {modalCss});
Expand Down
5 changes: 4 additions & 1 deletion static/app/bootstrap/processInitQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {createBrowserRouter, RouterProvider} from 'react-router-dom';
import throttle from 'lodash/throttle';

import {exportedGlobals} from 'sentry/bootstrap/exportGlobals';
import {CommandPaletteProvider} from 'sentry/components/commandPalette/context';
import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider';
import type {OnSentryInitConfiguration} from 'sentry/types/system';
import {SentryInitRenderReactComponent} from 'sentry/types/system';
Expand Down Expand Up @@ -110,7 +111,9 @@ async function processItem(initConfig: OnSentryInitConfiguration) {
*/
<QueryClientProvider client={queryClient}>
<ThemeAndStyleProvider>
<SimpleRouter element={<Component {...props} />} />
<CommandPaletteProvider>
<SimpleRouter element={<Component {...props} />} />
</CommandPaletteProvider>
</ThemeAndStyleProvider>
</QueryClientProvider>
),
Expand Down
84 changes: 84 additions & 0 deletions static/app/components/commandPalette/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {createContext, useCallback, useContext, useMemo, useState} from 'react';

import type {CommandPaletteAction} from './types';

type CommandPaletteProviderProps = {children: React.ReactNode};

type CommandPaletteStore = {
actions: CommandPaletteAction[];
};

type CommandPaletteConfig = {
registerActions: (actions: CommandPaletteAction[]) => void;
unregisterActions: (keys: string[]) => void;
};

const CommandPaletteConfigContext = createContext<CommandPaletteConfig | null>(null);
const CommandPaletteStoreContext = createContext<CommandPaletteStore | null>(null);

export function useCommandPaletteConfiguration(): CommandPaletteConfig {
const ctx = useContext(CommandPaletteConfigContext);
if (ctx === null) {
throw new Error('Must be wrapped in CommandPaletteProvider');
}
return ctx;
}

export function useCommandPaletteStore(): CommandPaletteStore {
const ctx = useContext(CommandPaletteStoreContext);
if (ctx === null) {
throw new Error('Must be wrapped in CommandPaletteProvider');
}
return ctx;
}

export function CommandPaletteProvider({children}: CommandPaletteProviderProps) {
const [actions, setActions] = useState<CommandPaletteAction[]>([]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action and usage pattern here would be more suitable for a reducer than useState. That way all that you need to pass to the reducers is just the dispatch function and the state, and you won't need to deal with any memo problems


const registerActions = useCallback((newActions: CommandPaletteAction[]) => {
setActions(prev => {
const result = [...prev];

for (const newAction of newActions) {
const existingIndex = result.findIndex(action => action.key === newAction.key);

if (existingIndex >= 0) {
result[existingIndex] = newAction;
} else {
result.push(newAction);
}
}

return result;
});
}, []);

const unregisterActions = useCallback((keys: string[]) => {
setActions(prev => {
return prev.filter(action => !keys.includes(action.key));
});
}, []);

const config = useMemo<CommandPaletteConfig>(
() => ({
registerActions,
unregisterActions,
}),
[registerActions, unregisterActions]
);

const store = useMemo<CommandPaletteStore>(
() => ({
actions,
}),
[actions]
);

return (
<CommandPaletteConfigContext.Provider value={config}>
<CommandPaletteStoreContext.Provider value={store}>
{children}
</CommandPaletteStoreContext.Provider>
</CommandPaletteConfigContext.Provider>
);
}
28 changes: 28 additions & 0 deletions static/app/components/commandPalette/types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type {ReactNode} from 'react';
import type {LocationDescriptor} from 'history';

export type CommandPaletteAction = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there are a couple of things that are mixed here that make the definitions a bit unreadable.

How about something like

{ 
key, group,keywords (+other action meta as top level keys)
and action a separate key that controls the rendering pieces
}

I would have honestly also preferred if we could delegate rendering to every place that registers the action

/** Unique identifier for this action */
key: string;
/** Primary text shown to the user */
label: string;
/** Icon to render for this action */
actionIcon?: ReactNode;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already an icon :)

Suggested change
actionIcon?: ReactNode;
icon?: ReactNode;

/** Nested actions to show when this action is selected */
children?: CommandPaletteAction[];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
children?: CommandPaletteAction[];
actions?: CommandPaletteAction[];

An action that can have sub-actions :)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes this is a better name!

/** Additional context or description */
details?: string;
/** Whether this action should keep the modal open after execution */
keepOpen?: boolean;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a separate keepOpen, what if onAction could return an optional bool to keep the modal open?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh I do like that better

/** Optional keywords to improve searchability */
keywords?: string[];
/**
* Execute a callback when the action is selected.
* Use the `to` prop if you want to navigate to a route.
*/
onAction?: () => void;
/** Section to group the action in the palette */
section?: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this influences grouping, should it be named something like groupingKey or group?

/** Navigate to a route when selected */
to?: LocationDescriptor;
};
135 changes: 135 additions & 0 deletions static/app/components/commandPalette/ui/content.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import * as modalActions from 'sentry/actionCreators/modal';
import {CommandPaletteProvider} from 'sentry/components/commandPalette/context';
import type {CommandPaletteAction} from 'sentry/components/commandPalette/types';
import {CommandPaletteContent} from 'sentry/components/commandPalette/ui/content';
import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions';

function RegisterActions({actions}: {actions: CommandPaletteAction[]}) {
useCommandPaletteActions(actions);
return null;
}

function GlobalActionsComponent({
actions,
children,
}: {
actions: CommandPaletteAction[];
children?: React.ReactNode;
}) {
return (
<CommandPaletteProvider>
<RegisterActions actions={actions} />
<CommandPaletteContent />
{children}
</CommandPaletteProvider>
);
}

const onChild = jest.fn();
const onKeepOpen = jest.fn();

const globalActions: CommandPaletteAction[] = [
{
key: 'go-to-route',
label: 'Go to route',
to: '/target/',
section: 'Navigation',
},
{key: 'other', label: 'Other', to: '/other/'},
{
key: 'parent-action',
label: 'Parent action',
section: 'Other',
children: [
{
key: 'child-action',
label: 'Child action',
onAction: onChild,
},
],
},
{
key: 'keep-open',
label: 'Keep open',
section: 'Other',
onAction: onKeepOpen,
keepOpen: true,
},
];

describe('CommandPaletteContent', () => {
beforeEach(() => {
jest.resetAllMocks();
});

it('clicking a link item navigates and closes modal', async () => {
const closeSpy = jest.spyOn(modalActions, 'closeModal');
const {router} = render(<GlobalActionsComponent actions={globalActions} />);
await userEvent.click(await screen.findByRole('option', {name: 'Go to route'}));

await waitFor(() => expect(router.location.pathname).toBe('/target/'));
expect(closeSpy).toHaveBeenCalledTimes(1);
});

it('ArrowDown to a link item then Enter navigates and closes modal', async () => {
const closeSpy = jest.spyOn(modalActions, 'closeModal');
const {router} = render(<GlobalActionsComponent actions={globalActions} />);
await screen.findByRole('textbox', {name: 'Search commands'});
// First item should already be highlighted, arrow down will go highlight "other"
await userEvent.keyboard('{ArrowDown}{Enter}');

await waitFor(() => expect(router.location.pathname).toBe('/other/'));
expect(closeSpy).toHaveBeenCalledTimes(1);
});

it('clicking action with children shows sub-items, backspace returns', async () => {
const closeSpy = jest.spyOn(modalActions, 'closeModal');
render(<GlobalActionsComponent actions={globalActions} />);

// Open children
await userEvent.click(await screen.findByRole('option', {name: 'Parent action'}));

// Textbox changes placeholder to parent action label
await waitFor(() => {
expect(screen.getByRole('textbox', {name: 'Search commands'})).toHaveAttribute(
'placeholder',
'Parent action'
);
});

// Child actions are visible, global actions are not
expect(screen.getByRole('option', {name: 'Child action'})).toBeInTheDocument();
expect(screen.queryByRole('option', {name: 'Parent action'})).not.toBeInTheDocument();
expect(screen.queryByRole('option', {name: 'Go to route'})).not.toBeInTheDocument();

// Hit Backspace on the input to go back
await userEvent.keyboard('{Backspace}');

// Back to main actions
expect(
await screen.findByRole('option', {name: 'Parent action'})
).toBeInTheDocument();
expect(screen.queryByRole('option', {name: 'Child action'})).not.toBeInTheDocument();

expect(closeSpy).not.toHaveBeenCalled();
});

it('clicking child sub-item runs onAction and closes modal', async () => {
const closeSpy = jest.spyOn(modalActions, 'closeModal');
render(<GlobalActionsComponent actions={globalActions} />);
await userEvent.click(await screen.findByRole('option', {name: 'Parent action'}));
await userEvent.click(await screen.findByRole('option', {name: 'Child action'}));

expect(onChild).toHaveBeenCalled();
expect(closeSpy).toHaveBeenCalledTimes(1);
});

it('keeps modal open after action with keepOpen flag', async () => {
const closeSpy = jest.spyOn(modalActions, 'closeModal');
render(<GlobalActionsComponent actions={globalActions} />);
await userEvent.click(await screen.findByRole('option', {name: 'Keep open'}));
expect(closeSpy).not.toHaveBeenCalled();
});
});
Loading
Loading