Command Palette Scaffolding + Explore (#47445)

* Add feature flag and scaffodling

* start adding actions

* WIP

* move action files

* Start adding styles

* Fix implementation based on feedback

* Add more hackathon code back to command palette

* Cleanup

* Cleanup unused service files for simple MVP pass

* Move type def to library

* WIP

* Move provider to proper place to pick up other routes’ actions

* Build actions off navbar, add explore actions

* Work around undefined typescript stuff

* Fix based on feedback

* close palette on ESC

* Fix based on PR feedback pt 1

* Move styles to classes

* Move another inline style to a class

* Enable command palette by default

* change around async hook structure

* Add simple feature tracking

* Code cleanup, and be sure the command is accurate

* Change to only render if there are actions, and only add actions once past login
This commit is contained in:
Kristina 2022-04-21 16:50:34 -05:00 committed by GitHub
parent 75d528d7bd
commit 3e7db088ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 676 additions and 24 deletions

View File

@ -1140,6 +1140,9 @@ promQueryBuilder = true
# Experimental Explore to Dashboard workflow
explore2Dashboard = true
# Experimental Command Palette
commandPalette = true
# feature1 = true
# feature2 = false

View File

@ -319,6 +319,7 @@
"js-yaml": "^4.1.0",
"json-source-map": "0.6.1",
"jsurl": "^0.1.5",
"kbar": "^0.1.0-beta.30",
"lezer-promql": "0.22.0",
"lodash": "4.17.21",
"logfmt": "^1.3.2",

View File

@ -55,4 +55,5 @@ export interface FeatureToggles {
azureMonitorResourcePickerForMetrics?: boolean;
explore2Dashboard?: boolean;
persistNotifications?: boolean;
commandPalette?: boolean;
}

View File

@ -219,5 +219,10 @@ var (
State: FeatureStateAlpha,
FrontendOnly: true,
},
{
Name: "commandPalette",
Description: "Enable command palette",
State: FeatureStateAlpha,
},
}
)

View File

@ -162,4 +162,8 @@ const (
// FlagPersistNotifications
// PoC Notifications page
FlagPersistNotifications = "persistNotifications"
// FlagCommandPalette
// Enable command palette
FlagCommandPalette = "commandPalette"
)

View File

@ -1,6 +1,6 @@
import React, { ComponentType } from 'react';
import { Router, Route, Redirect, Switch } from 'react-router-dom';
import { config, locationService, navigationLogger } from '@grafana/runtime';
import { config, locationService, navigationLogger, reportInteraction } from '@grafana/runtime';
import { Provider } from 'react-redux';
import { store } from 'app/store/store';
import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, ModalsProvider, PortalContainer } from '@grafana/ui';
@ -15,6 +15,8 @@ import { GrafanaRoute } from './core/navigation/GrafanaRoute';
import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList';
import { SearchWrapper } from 'app/features/search';
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
import { Action, KBarProvider } from 'kbar';
import { CommandPalette } from './features/commandPalette/CommandPalette';
import { I18nProvider } from './core/localisation';
import { AngularRoot } from './angular/AngularRoot';
import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled';
@ -85,14 +87,25 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
const newNavigationEnabled = Boolean(config.featureToggles.newNavigation);
const commandPaletteActionSelected = (action: Action) => {
reportInteraction('commandPalette_action_selected', {
actionId: action.id,
});
};
return (
<Provider store={store}>
<I18nProvider>
<ErrorBoundaryAlert style="page">
<ConfigContext.Provider value={config}>
<ThemeProvider>
<KBarProvider
actions={[]}
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
>
<ModalsProvider>
<GlobalStyles />
{config.featureToggles.commandPalette && <CommandPalette />}
<div className="grafana-app">
<Router history={locationService.getHistory()}>
{ready && <>{newNavigationEnabled ? <NavBarNext /> : <NavBar />}</>}
@ -115,6 +128,7 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
<ModalRoot />
<PortalContainer />
</ModalsProvider>
</KBarProvider>
</ThemeProvider>
</ConfigContext.Provider>
</ErrorBoundaryAlert>

View File

@ -0,0 +1,146 @@
import React, { useEffect, useState } from 'react';
import {
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarResults,
KBarSearch,
useMatches,
Action,
VisualState,
useRegisterActions,
useKBar,
} from 'kbar';
import { useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { ResultItem } from './ResultItem';
import getGlobalActions from './actions/global.static.actions';
import getDashboardNavActions from './actions/dashboard.nav.actions';
import { useSelector } from 'react-redux';
import { StoreState } from 'app/types';
import { css } from '@emotion/css';
import { keybindingSrv } from '../../core/services/keybindingSrv';
import { reportInteraction, locationService } from '@grafana/runtime';
/**
* Wrap all the components from KBar here.
* @constructor
*/
export const CommandPalette = () => {
const styles = useStyles2(getSearchStyles);
const [actions, setActions] = useState<Action[]>([]);
const { notHidden, query, showing } = useKBar((state) => ({
notHidden: state.visualState !== VisualState.hidden,
showing: state.visualState === VisualState.showing,
}));
const isNotLogin = locationService.getLocation().pathname !== '/login';
const { navBarTree } = useSelector((state: StoreState) => {
return {
navBarTree: state.navBarTree,
};
});
keybindingSrv.bind('esc', () => {
if (notHidden) {
query.setVisualState(VisualState.animatingOut);
}
});
useEffect(() => {
(async () => {
if (isNotLogin) {
const staticActions = getGlobalActions(navBarTree);
const dashAct = await getDashboardNavActions('go/dashboard');
setActions([...staticActions, ...dashAct]);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNotLogin]);
useEffect(() => {
if (showing) {
reportInteraction('commandPalette_opened');
}
}, [showing]);
useRegisterActions(actions, [actions]);
return actions.length > 0 ? (
<KBarPortal>
<KBarPositioner className={styles.positioner}>
<KBarAnimator className={styles.animator}>
<KBarSearch className={styles.search} />
<RenderResults />
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
) : null;
};
const RenderResults = () => {
const { results, rootActionId } = useMatches();
const styles = useStyles2(getSearchStyles);
return (
<div className={styles.resultsContainer}>
<KBarResults
items={results}
onRender={({ item, active }) =>
typeof item === 'string' ? (
<div className={styles.sectionHeader}>{item}</div>
) : (
<ResultItem action={item} active={active} currentRootActionId={rootActionId!} />
)
}
/>
</div>
);
};
const getSearchStyles = (theme: GrafanaTheme2) => ({
positioner: css({
zIndex: theme.zIndex.portal,
marginTop: '0px',
'&::before': {
content: '""',
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
left: 0,
background: theme.components.overlay.background,
backdropFilter: 'blur(1px)',
},
}),
animator: css({
maxWidth: theme.breakpoints.values.sm, // supposed to be 600...
width: '100%',
background: theme.colors.background.canvas,
color: theme.colors.text.primary,
borderRadius: theme.shape.borderRadius(4),
overflow: 'hidden',
boxShadow: theme.shadows.z3,
}),
search: css({
padding: theme.spacing(2, 3),
fontSize: theme.typography.fontSize,
width: '100%',
boxSizing: 'border-box',
outline: 'none',
border: 'none',
background: theme.colors.background.canvas,
color: theme.colors.text.primary,
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
sectionHeader: css({
padding: theme.spacing(1, 2),
fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.body.fontWeight,
color: theme.colors.text.secondary,
}),
resultsContainer: css({
padding: theme.spacing(2, 0),
}),
});

View File

@ -0,0 +1,123 @@
import React from 'react';
import { ActionId, ActionImpl } from 'kbar';
import { useTheme2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
export const ResultItem = React.forwardRef(
(
{
action,
active,
currentRootActionId,
}: {
action: ActionImpl;
active: boolean;
currentRootActionId: ActionId;
},
ref: React.Ref<HTMLDivElement>
) => {
const ancestors = React.useMemo(() => {
if (!currentRootActionId) {
return action.ancestors;
}
const index = action.ancestors.findIndex((ancestor) => ancestor.id === currentRootActionId);
// +1 removes the currentRootAction; e.g.
// if we are on the "Set theme" parent action,
// the UI should not display "Set theme… > Dark"
// but rather just "Dark"
return action.ancestors.slice(index + 1);
}, [action.ancestors, currentRootActionId]);
const theme = useTheme2();
const styles = getResultItemStyles(theme, active);
return (
<div ref={ref} className={styles.row}>
<div className={styles.actionContainer}>
{action.icon}
<div className={styles.textContainer}>
<div>
{ancestors.length > 0 &&
ancestors.map((ancestor) => (
<React.Fragment key={ancestor.id}>
<span className={styles.breadcrumbAncestor}>{ancestor.name}</span>
<span className={styles.breadcrumbAncestor}>&rsaquo;</span>
</React.Fragment>
))}
<span>{action.name}</span>
</div>
</div>
{action.subtitle && <span className={styles.subtitleText}>{action.subtitle}</span>}
</div>
{action.shortcut?.length ? (
<div aria-hidden className={styles.shortcutContainer}>
{action.shortcut.map((sc) => (
<kbd key={sc} className={styles.shortcut}>
{sc}
</kbd>
))}
</div>
) : null}
</div>
);
}
);
ResultItem.displayName = 'ResultItem';
const getResultItemStyles = (theme: GrafanaTheme2, isActive: boolean) => {
const textColor = isActive ? theme.colors.text.maxContrast : theme.colors.text.primary;
const rowBackgroundColor = isActive ? theme.colors.background.primary : 'transparent';
const shortcutBackgroundColor = isActive ? theme.colors.background.secondary : theme.colors.background.primary;
return {
row: css({
color: textColor,
padding: theme.spacing(1, 2),
background: rowBackgroundColor,
display: 'flex',
alightItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
'&:before': {
display: isActive ? 'block' : 'none',
content: '" "',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius(1),
backgroundImage: theme.colors.gradients.brandVertical,
},
}),
actionContainer: css({
display: 'flex',
gap: theme.spacing(2),
alignitems: 'center',
fontsize: theme.typography.fontSize,
}),
textContainer: css({
display: 'flex',
flexDirection: 'column',
}),
shortcut: css({
padding: theme.spacing(0, 1),
background: shortcutBackgroundColor,
borderRadius: theme.shape.borderRadius(),
fontsize: theme.typography.fontSize,
}),
breadcrumbAncestor: css({
opacity: 0.5,
marginRight: theme.spacing(1),
}),
subtitleText: css({
fontSize: theme.typography.fontSize - 2,
}),
shortcutContainer: css({
display: 'grid',
gridAutoFlow: 'column',
gap: theme.spacing(1),
}),
};
};

View File

@ -0,0 +1,24 @@
import { locationService, getBackendSrv } from '@grafana/runtime';
import { Action } from 'kbar';
async function getDashboardNav(parentId: string): Promise<Action[]> {
const data: Array<{ type: string; title: string; url: string }> = await getBackendSrv().get('/api/search');
const goToDashboardActions: Action[] = data
.filter((item) => item.type === 'dash-db')
.map((item) => ({
parent: parentId,
id: `go/dashboard/${item.url}`,
name: `Go to dashboard ${item.title}`,
perform: () => {
locationService.push(item.url);
},
}));
return goToDashboardActions;
}
export default async (parentId: string) => {
const dashboardNav = await getDashboardNav(parentId);
return dashboardNav;
};

View File

@ -0,0 +1,152 @@
import { locationService } from '@grafana/runtime';
import { NavModelItem } from '@grafana/data';
import { Action, Priority } from 'kbar';
export default (navBarTree: NavModelItem[]) => {
const globalActions: Action[] = [
{
id: 'go/search',
name: 'Go to dashboard search',
keywords: 'navigate',
perform: () => locationService.push('?search=open'),
section: 'Navigation',
shortcut: ['s', 'o'],
},
{
id: 'go/dashboard',
name: 'Go to dashboard...',
keywords: 'navigate',
section: 'Navigation',
priority: Priority.NORMAL,
},
{
id: 'preferences/theme',
name: 'Change theme...',
keywords: 'interface color dark light',
section: 'Preferences',
},
{
id: 'preferences/dark-theme',
name: 'Dark',
keywords: 'dark theme',
section: '',
perform: () => {
locationService.push({ search: '?theme=dark' });
location.reload();
},
parent: 'preferences/theme',
},
{
id: 'preferences/light-theme',
name: 'Light',
keywords: 'light theme',
section: '',
perform: () => {
locationService.push({ search: '?theme=light' });
location.reload();
},
parent: 'preferences/theme',
},
];
// this maps actions to navbar by URL items for showing/hiding
// actions is an array for multiple child actions that would be under one navbar item
const navBarActionMap = [
{
url: '/dashboard/new',
actions: [
{
id: 'management/create-folder',
name: 'Create folder',
keywords: 'new add',
perform: () => locationService.push('/dashboards/folder/new'),
section: 'Management',
},
{
id: 'management/create-dashboard',
name: 'Create dashboard',
keywords: 'new add',
perform: () => locationService.push('/dashboard/new'),
section: 'Management',
},
],
},
{
url: '/',
actions: [
{
id: 'go/home',
name: 'Go to home',
keywords: 'navigate',
perform: () => locationService.push('/'),
section: 'Navigation',
shortcut: ['g', 'h'],
priority: Priority.HIGH,
},
],
},
{
url: '/explore',
actions: [
{
id: 'go/explore',
name: 'Go to explore',
keywords: 'navigate',
perform: () => locationService.push('/explore'),
section: 'Navigation',
priority: Priority.NORMAL,
},
],
},
{
url: '/alerting',
actions: [
{
id: 'go/alerting',
name: 'Go to alerting',
keywords: 'navigate notification',
perform: () => locationService.push('/alerting'),
section: 'Navigation',
priority: Priority.NORMAL,
},
],
},
{
url: '/profile',
actions: [
{
id: 'go/profile',
name: 'Go to profile',
keywords: 'navigate preferences',
perform: () => locationService.push('/profile'),
section: 'Navigation',
shortcut: ['g', 'p'],
priority: Priority.LOW,
},
],
},
{
url: '/datasources',
actions: [
{
id: 'go/configuration',
name: 'Go to data sources configuration',
keywords: 'navigate settings ds',
perform: () => locationService.push('/datasources'),
section: 'Navigation',
},
],
},
];
const navBarActions: Action[] = [];
navBarActionMap.forEach((navBarAction) => {
const navBarItem = navBarTree.find((navBarItem) => navBarItem.url === navBarAction.url);
if (navBarItem && !navBarItem.hideFromNavbar) {
navBarActions.push(...navBarAction.actions);
}
});
return [...globalActions, ...navBarActions];
};

View File

@ -0,0 +1,94 @@
import { FC, useEffect, useState } from 'react';
import { useRegisterActions, useKBar, Action, Priority } from 'kbar';
import { useDispatch, useSelector } from 'react-redux';
import { ExploreId } from 'app/types';
import { splitOpen, splitClose } from './state/main';
import { isSplit } from './state/selectors';
import { runQueries } from './state/query';
interface Props {
exploreIdLeft: ExploreId;
exploreIdRight?: ExploreId;
}
export const ExploreActions: FC<Props> = ({ exploreIdLeft, exploreIdRight }: Props) => {
const [actions, setActions] = useState<Action[]>([]);
const { query } = useKBar();
const dispatch = useDispatch();
const splitted = useSelector(isSplit);
useEffect(() => {
const exploreSection = {
name: 'Explore',
priority: Priority.HIGH + 1,
};
const actionsArr: Action[] = [];
if (splitted) {
actionsArr.push({
id: 'explore/run-query-left',
name: 'Run Query (Left)',
keywords: 'query left',
perform: () => {
dispatch(runQueries(exploreIdLeft));
},
section: exploreSection,
});
if (exploreIdRight) {
// we should always have the right exploreId if split
actionsArr.push({
id: 'explore/run-query-right',
name: 'Run Query (Right)',
keywords: 'query right',
perform: () => {
dispatch(runQueries(exploreIdRight));
},
section: exploreSection,
});
actionsArr.push({
id: 'explore/split-view-close-left',
name: 'Close split view left',
keywords: 'split',
perform: () => {
dispatch(splitClose(exploreIdLeft));
},
section: exploreSection,
});
actionsArr.push({
id: 'explore/split-view-close-right',
name: 'Close split view right',
keywords: 'split',
perform: () => {
dispatch(splitClose(exploreIdRight));
},
section: exploreSection,
});
}
} else {
actionsArr.push({
id: 'explore/run-query',
name: 'Run Query',
keywords: 'query',
perform: () => {
dispatch(runQueries(exploreIdLeft));
},
section: exploreSection,
});
actionsArr.push({
id: 'explore/split-view-open',
name: 'Open split view',
keywords: 'split',
perform: () => {
dispatch(splitOpen());
},
section: exploreSection,
});
}
setActions(actionsArr);
}, [exploreIdLeft, exploreIdRight, splitted, query, dispatch]);
useRegisterActions(!query ? [] : actions, [actions, query]);
return null;
};

View File

@ -6,6 +6,7 @@ import { lastSavedUrl, resetExploreAction, richHistoryUpdatedAction } from './st
import { ExplorePaneContainer } from './ExplorePaneContainer';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { Branding } from '../../core/components/Branding/Branding';
import { ExploreActions } from './ExploreActions';
import { getNavModel } from '../../core/selectors/navModel';
import { StoreState } from 'app/types';
@ -69,6 +70,7 @@ class WrapperUnconnected extends PureComponent<Props> {
return (
<div className="page-scrollbar-wrapper">
<ExploreActions exploreIdLeft={ExploreId.left} exploreIdRight={ExploreId.right} />
<div className="explore-wrapper">
<ErrorBoundaryAlert style="page">
<ExplorePaneContainer split={hasSplit} exploreId={ExploreId.left} urlQuery={left} />

View File

@ -6881,6 +6881,40 @@ __metadata:
languageName: node
linkType: hard
"@reach/observe-rect@npm:^1.1.0":
version: 1.2.0
resolution: "@reach/observe-rect@npm:1.2.0"
checksum: 7dd903eeaad0e22c6d973bd26265d91eadba56ab5134701ceb3e85214db75339fae94aa7e8b88a65e8daa64bc7cf1b915d4ffcdfd324466b561dc6adc3c6e070
languageName: node
linkType: hard
"@reach/portal@npm:^0.16.0":
version: 0.16.2
resolution: "@reach/portal@npm:0.16.2"
dependencies:
"@reach/utils": 0.16.0
tiny-warning: ^1.0.3
tslib: ^2.3.0
peerDependencies:
react: ^16.8.0 || 17.x
react-dom: ^16.8.0 || 17.x
checksum: 7413dcd169cfb9854dd0d3a01f811ec19ef170558fcbd00118d676fc02c197d1c0bce1d1357508879d4775169561d103e13a8e4d74cc677eb0037cc7b04f7a1e
languageName: node
linkType: hard
"@reach/utils@npm:0.16.0":
version: 0.16.0
resolution: "@reach/utils@npm:0.16.0"
dependencies:
tiny-warning: ^1.0.3
tslib: ^2.3.0
peerDependencies:
react: ^16.8.0 || 17.x
react-dom: ^16.8.0 || 17.x
checksum: 36bc0eb41a71798eb6186b23de265ba709e51dae5bf214fb8505c66bb3f2e6a41bb2401457350436ba89ca9e3a50f93a04fe7c33d15648ce11e568a85622d770
languageName: node
linkType: hard
"@react-aria/button@npm:3.4.3":
version: 3.4.3
resolution: "@react-aria/button@npm:3.4.3"
@ -15087,6 +15121,13 @@ __metadata:
languageName: node
linkType: hard
"command-score@npm:^0.1.2":
version: 0.1.2
resolution: "command-score@npm:0.1.2"
checksum: b733fd552d7e569070da3d474b1ed5f54785fdf3dd61670002e0a00b2eff1a547c2b6d3af3683c012f4f39c6455f9e7ee5e9997a79c08048ec37ec2195d3df08
languageName: node
linkType: hard
"commander@npm:*, commander@npm:8.3.0, commander@npm:^8.0.0, commander@npm:^8.1.0, commander@npm:^8.3.0":
version: 8.3.0
resolution: "commander@npm:8.3.0"
@ -18998,6 +19039,13 @@ __metadata:
languageName: node
linkType: hard
"fast-equals@npm:^2.0.3":
version: 2.0.4
resolution: "fast-equals@npm:2.0.4"
checksum: 1aac8a2e16b33e5e402bb5cd46be65f1ca331903c2c44e3bd75324e8472ee04f0acdbc6889e45fc28f9707ca3964f2a86789b32305d4d8b85dc97f61e446ef4b
languageName: node
linkType: hard
"fast-glob@npm:^2.2.6":
version: 2.2.7
resolution: "fast-glob@npm:2.2.7"
@ -20585,6 +20633,7 @@ __metadata:
js-yaml: ^4.1.0
json-source-map: 0.6.1
jsurl: ^0.1.5
kbar: ^0.1.0-beta.30
lerna: ^4.0.0
lezer-promql: 0.22.0
lint-staged: 12.3.7
@ -24493,6 +24542,22 @@ __metadata:
languageName: node
linkType: hard
"kbar@npm:^0.1.0-beta.30":
version: 0.1.0-beta.33
resolution: "kbar@npm:0.1.0-beta.33"
dependencies:
"@reach/portal": ^0.16.0
command-score: ^0.1.2
fast-equals: ^2.0.3
react-virtual: ^2.8.2
tiny-invariant: ^1.2.0
peerDependencies:
react: ^16.0.0 || ^17.0.0
react-dom: ^16.0.0 || ^17.0.0
checksum: 545720900a046501157f8aab9530f62dec356438b85f728968fdc08192cc65afd903cd27e3a0c0f92a74ad6d15d808f5eb12c8b6a801cf881208e3dc2594eb8b
languageName: node
linkType: hard
"keycharm@npm:^0.2.0":
version: 0.2.0
resolution: "keycharm@npm:0.2.0"
@ -31317,6 +31382,17 @@ __metadata:
languageName: node
linkType: hard
"react-virtual@npm:^2.8.2":
version: 2.10.4
resolution: "react-virtual@npm:2.10.4"
dependencies:
"@reach/observe-rect": ^1.1.0
peerDependencies:
react: ^16.6.3 || ^17.0.0
checksum: 1bebc741b01057829a7d7f29256114caecf0597d41b187cb41e75af77f24a87c780bc1a81ec11205b78ee2e9c801fc5e36b20a9e1ab7ddc70a18dd95417795f8
languageName: node
linkType: hard
"react-virtualized-auto-sizer@npm:1.0.6":
version: 1.0.6
resolution: "react-virtualized-auto-sizer@npm:1.0.6"
@ -34919,6 +34995,13 @@ __metadata:
languageName: node
linkType: hard
"tiny-invariant@npm:^1.2.0":
version: 1.2.0
resolution: "tiny-invariant@npm:1.2.0"
checksum: e09a718a7c4a499ba592cdac61f015d87427a0867ca07f50c11fd9b623f90cdba18937b515d4a5e4f43dac92370498d7bdaee0d0e7a377a61095e02c4a92eade
languageName: node
linkType: hard
"tiny-warning@npm:^0.0.3":
version: 0.0.3
resolution: "tiny-warning@npm:0.0.3"