CommandPalette: Search for dashboards using API (#61090)

* CommandPalette: Search for dashboards using API

* Fix ordering of dashboards

* Put recent + search dashboards in root list, refactor actions into hook

* limit recent dashboards to 5

* search debounce to 200ms

* update priorities

* extract i18n
This commit is contained in:
Josh Hunt 2023-01-10 14:59:32 +00:00 committed by GitHub
parent 9a14a7db03
commit 5d725d22ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 285 additions and 129 deletions

View File

@ -91,7 +91,8 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
}
commandPaletteEnabled() {
return config.featureToggles.commandPalette && !config.isPublicDashboardView;
const isLoginPage = locationService.getLocation().pathname === '/login';
return config.featureToggles.commandPalette && !config.isPublicDashboardView && !isLoginPage;
}
searchBarEnabled() {

View File

@ -9,21 +9,18 @@ import {
KBarResults,
KBarSearch,
useMatches,
Action,
VisualState,
useRegisterActions,
useKBar,
} from 'kbar';
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction, locationService } from '@grafana/runtime';
import { reportInteraction } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { useSelector } from 'app/types';
import { ResultItem } from './ResultItem';
import getDashboardNavActions from './actions/dashboard.nav.actions';
import getGlobalActions from './actions/global.static.actions';
import useActions from './actions/useActions';
/**
* Wrap all the components from KBar here.
@ -32,18 +29,14 @@ import getGlobalActions from './actions/global.static.actions';
export const CommandPalette = () => {
const styles = useStyles2(getSearchStyles);
const [actions, setActions] = useState<Action[]>([]);
const [staticActions, setStaticActions] = useState<Action[]>([]);
const { query, showing } = useKBar((state) => ({
showing: state.visualState === VisualState.showing,
}));
const isNotLogin = locationService.getLocation().pathname !== '/login';
const { navBarTree } = useSelector((state) => {
return {
navBarTree: state.navBarTree,
};
});
const { query, showing, searchQuery } = useKBar((state) => ({
showing: state.visualState === VisualState.showing,
searchQuery: state.searchQuery,
}));
const actions = useActions(searchQuery, showing);
useRegisterActions(actions, [actions]);
const ref = useRef<HTMLDivElement>(null);
const { overlayProps } = useOverlay(
@ -52,26 +45,10 @@ export const CommandPalette = () => {
);
const { dialogProps } = useDialog({}, ref);
// Report interaction when opened
useEffect(() => {
if (isNotLogin) {
const staticActionsResp = getGlobalActions(navBarTree);
setStaticActions(staticActionsResp);
setActions([...staticActionsResp]);
}
}, [isNotLogin, navBarTree]);
useEffect(() => {
if (showing) {
reportInteraction('command_palette_opened');
// Do dashboard search on demand
getDashboardNavActions('go/dashboard').then((dashAct) => {
setActions([...staticActions, ...dashAct]);
});
}
}, [showing, staticActions]);
useRegisterActions(actions, [actions]);
showing && reportInteraction('command_palette_opened');
}, [showing]);
return actions.length > 0 ? (
<KBarPortal>

View File

@ -1,32 +0,0 @@
import { Action } from 'kbar';
import { locationUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { getGrafanaSearcher } from 'app/features/search/service';
async function getDashboardNav(parentId: string): Promise<Action[]> {
const data = await getGrafanaSearcher().search({
kind: ['dashboard'],
query: '*',
limit: 500,
});
const goToDashboardActions: Action[] = data.view.map((item) => {
const { url, name } = item; // items are backed by DataFrameView, so must hold the url in a closure
return {
parent: parentId,
id: `go/dashboard/${url}`,
name: `${name}`,
perform: () => {
locationService.push(locationUtil.stripBaseFromUrl(url));
},
};
});
return goToDashboardActions;
}
export default async (parentId: string) => {
const dashboardNav = await getDashboardNav(parentId);
return dashboardNav;
};

View File

@ -0,0 +1,76 @@
import { locationUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
import impressionSrv from 'app/core/services/impression_srv';
import { getGrafanaSearcher } from 'app/features/search/service';
import { CommandPaletteAction } from '../types';
import { RECENT_DASHBOARDS_PRORITY, SEARCH_RESULTS_PRORITY } from '../values';
const MAX_SEARCH_RESULTS = 100;
const MAX_RECENT_DASHBOARDS = 5;
export async function getRecentDashboardActions(): Promise<CommandPaletteAction[]> {
const recentUids = (await impressionSrv.getDashboardOpened()).slice(0, MAX_RECENT_DASHBOARDS);
const resultsDataFrame = await getGrafanaSearcher().search({
kind: ['dashboard'],
limit: MAX_RECENT_DASHBOARDS,
uid: recentUids,
});
// Search results are alphabetical, so reorder them according to recently viewed
const recentResults = resultsDataFrame.view.toArray();
recentResults.sort((resultA, resultB) => {
const orderA = recentUids.indexOf(resultA.uid);
const orderB = recentUids.indexOf(resultB.uid);
return orderA - orderB;
});
const recentDashboardActions: CommandPaletteAction[] = recentResults.map((item) => {
const { url, name } = item; // items are backed by DataFrameView, so must hold the url in a closure
return {
id: `recent-dashboards/${url}`,
name: `${name}`,
section: t('command-palette.section.recent-dashboards', 'Recently viewed dashboards'),
priority: RECENT_DASHBOARDS_PRORITY,
perform: () => {
locationService.push(locationUtil.stripBaseFromUrl(url));
},
};
});
return recentDashboardActions;
}
export async function getDashboardSearchResultActions(searchQuery: string): Promise<CommandPaletteAction[]> {
// Empty strings should not come through to here
if (searchQuery.length === 0) {
return [];
}
const data = await getGrafanaSearcher().search({
kind: ['dashboard'],
query: searchQuery,
limit: MAX_SEARCH_RESULTS,
});
const goToDashboardActions: CommandPaletteAction[] = data.view.map((item) => {
const { url, name } = item; // items are backed by DataFrameView, so must hold the url in a closure
return {
id: `go/dashboard/${url}`,
name: `${name}`,
section: t('command-palette.section.dashboard-search-results', 'Dashboards'),
priority: SEARCH_RESULTS_PRORITY,
perform: () => {
locationService.push(locationUtil.stripBaseFromUrl(url));
},
};
});
return goToDashboardActions;
}
// export default async (parentId: string) => {
// const dashboardNav = await getDashboardNav(parentId);
// return dashboardNav;
// };

View File

@ -1,27 +1,21 @@
import { Action, Priority } from 'kbar';
import React from 'react';
import { isIconName, locationUtil, NavModelItem } from '@grafana/data';
import { locationUtil, NavModelItem } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { Icon } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { changeTheme } from 'app/core/services/theme';
const SECTION_PAGES = 'Pages';
const SECTION_ACTIONS = 'Actions';
const SECTION_PREFERENCES = 'Preferences';
import { CommandPaletteAction } from '../types';
import { DEFAULT_PRIORITY, PREFERENCES_PRIORITY } from '../values';
export interface NavBarActions {
url: string;
actions: Action[];
}
// We reuse this, but translations cannot be in module scope (t must be called after i18n has set up,)
const getPagesSectionTranslation = () => t('command-palette.section.pages', 'Pages');
// TODO: Clean this once ID is mandatory on nav items
function idForNavItem(navItem: NavModelItem) {
return 'navModel.' + navItem.id ?? navItem.url ?? navItem.text ?? navItem.subTitle;
}
function navTreeToActions(navTree: NavModelItem[], parent?: NavModelItem): Action[] {
const navActions: Action[] = [];
function navTreeToActions(navTree: NavModelItem[], parent?: NavModelItem): CommandPaletteAction[] {
const navActions: CommandPaletteAction[] = [];
for (const navItem of navTree) {
const { url, text, isCreateAction, children } = navItem;
@ -31,15 +25,15 @@ function navTreeToActions(navTree: NavModelItem[], parent?: NavModelItem): Actio
continue;
}
const action: Action = {
const section = isCreateAction ? t('command-palette.section.actions', 'Actions') : getPagesSectionTranslation();
const action = {
id: idForNavItem(navItem),
name: text, // TODO: translate
section: isCreateAction ? SECTION_ACTIONS : SECTION_PAGES,
section: section,
perform: url ? () => locationService.push(locationUtil.stripBaseFromUrl(url)) : undefined,
parent: parent && idForNavItem(parent),
// Only show icons for top level items
icon: !parent && iconForNavItem(navItem),
priority: DEFAULT_PRIORITY,
};
navActions.push(action);
@ -53,46 +47,40 @@ function navTreeToActions(navTree: NavModelItem[], parent?: NavModelItem): Actio
return navActions;
}
export default (navBarTree: NavModelItem[]) => {
const globalActions: Action[] = [
{
// TODO: Figure out what section, if any, to put this in
id: 'go/dashboard',
name: 'Dashboards...',
keywords: 'navigate',
priority: Priority.NORMAL,
},
export default (navBarTree: NavModelItem[]): CommandPaletteAction[] => {
const globalActions: CommandPaletteAction[] = [
{
id: 'go/search',
name: 'Search',
name: t('command-palette.action.search', 'Search'),
keywords: 'navigate',
icon: <Icon name="search" size="md" />,
perform: () => locationService.push('?search=open'),
section: SECTION_PAGES,
section: t('command-palette.section.pages', 'Pages'),
shortcut: ['s', 'o'],
priority: DEFAULT_PRIORITY,
},
{
id: 'preferences/theme',
name: 'Change theme...',
name: t('command-palette.action.change-theme', 'Change theme...'),
keywords: 'interface color dark light',
section: SECTION_PREFERENCES,
section: t('command-palette.section.preferences', 'Preferences'),
shortcut: ['c', 't'],
priority: PREFERENCES_PRIORITY,
},
{
id: 'preferences/dark-theme',
name: 'Dark',
name: t('command-palette.action.dark-theme', 'Dark'),
keywords: 'dark theme',
section: '',
perform: () => changeTheme('dark'),
parent: 'preferences/theme',
priority: PREFERENCES_PRIORITY,
},
{
id: 'preferences/light-theme',
name: 'Light',
name: t('command-palette.action.light-theme', 'Light'),
keywords: 'light theme',
section: '',
perform: () => changeTheme('light'),
parent: 'preferences/theme',
priority: PREFERENCES_PRIORITY,
},
];
@ -100,13 +88,3 @@ export default (navBarTree: NavModelItem[]) => {
return [...globalActions, ...navBarActions];
};
function iconForNavItem(navItem: NavModelItem) {
if (navItem.icon && isIconName(navItem.icon)) {
return <Icon name={navItem.icon} size="md" />;
} else if (navItem.img) {
return <img alt="" src={navItem.img} style={{ width: 16, height: 16 }} />;
}
return undefined;
}

View File

@ -0,0 +1,50 @@
import debounce from 'debounce-promise';
import { useEffect, useMemo, useState } from 'react';
import { useSelector } from 'app/types';
import { CommandPaletteAction } from '../types';
import { getDashboardSearchResultActions, getRecentDashboardActions } from './dashboardActions';
import getStaticActions from './staticActions';
const debouncedDashboardSearch = debounce(getDashboardSearchResultActions, 200);
export default function useActions(searchQuery: string, isShowing: boolean) {
const [staticActions, setStaticActions] = useState<CommandPaletteAction[]>([]);
const [dashboardResultActions, setDashboardResultActions] = useState<CommandPaletteAction[]>([]);
const { navBarTree } = useSelector((state) => {
return {
navBarTree: state.navBarTree,
};
});
// Load standard static actions
useEffect(() => {
const staticActionsResp = getStaticActions(navBarTree);
setStaticActions(staticActionsResp);
}, [navBarTree]);
// Load recent dashboards - we don't want them to reload when the nav tree changes
useEffect(() => {
getRecentDashboardActions()
.then((recentDashboardActions) => setStaticActions((v) => [...v, ...recentDashboardActions]))
.catch((err) => {
console.error('Error loading recent dashboard actions', err);
});
}, []);
// Hit dashboards API
useEffect(() => {
if (isShowing && searchQuery.length > 0) {
debouncedDashboardSearch(searchQuery).then((resultActions) => {
setDashboardResultActions(resultActions);
});
}
}, [isShowing, searchQuery]);
const actions = useMemo(() => [...staticActions, ...dashboardResultActions], [staticActions, dashboardResultActions]);
return actions;
}

View File

@ -0,0 +1,18 @@
import { Action } from 'kbar';
type NotNullable<T> = Exclude<T, null | undefined>;
// Create our own action type to make priority mandatory.
// Parent actions require a section, but not child actions
export type CommandPaletteAction = RootCommandPaletteAction | ChildCommandPaletteAction;
type RootCommandPaletteAction = Omit<Action, 'parent'> & {
section: NotNullable<Action['section']>;
priority: NotNullable<Action['priority']>;
};
type ChildCommandPaletteAction = Action & {
parent: NotNullable<Action['parent']>;
priority: NotNullable<Action['priority']>;
};

View File

@ -0,0 +1,4 @@
export const RECENT_DASHBOARDS_PRORITY = 4;
export const DEFAULT_PRIORITY = 3;
export const PREFERENCES_PRIORITY = 2;
export const SEARCH_RESULTS_PRORITY = 1; // Dynamic actions should be below static ones so the list doesn't 'jump' when they come in

View File

@ -5,6 +5,21 @@
"success": "Kopiert"
}
},
"command-palette": {
"action": {
"change-theme": "",
"dark-theme": "",
"light-theme": "",
"search": ""
},
"section": {
"actions": "",
"dashboard-search-results": "",
"pages": "",
"preferences": "",
"recent-dashboards": ""
}
},
"common": {
"locale": {
"default": "Standard"
@ -83,7 +98,6 @@
"datasource-onboarding": {
"contact-admin": "Bitte wenden Sie sich an Ihren Administrator, um die Datenquellen zu konfigurieren.",
"explanation": "Um Ihre Daten zu visualisieren, müssen Sie sie zunächst verknüpfen.",
"logo": "Logo für die Datenquelle {{datasourceName}}",
"new-dashboard": "Neues Dashboard",
"preferred": "Verbinden Sie Ihre bevorzugte Datenquelle:",
"sampleData": "Oder erstellen Sie ein neues Dashboard mit Beispieldaten",

View File

@ -5,6 +5,21 @@
"success": "Copied"
}
},
"command-palette": {
"action": {
"change-theme": "Change theme...",
"dark-theme": "Dark",
"light-theme": "Light",
"search": "Search"
},
"section": {
"actions": "Actions",
"dashboard-search-results": "Dashboards",
"pages": "Pages",
"preferences": "Preferences",
"recent-dashboards": "Recently viewed dashboards"
}
},
"common": {
"locale": {
"default": "Default"
@ -83,7 +98,6 @@
"datasource-onboarding": {
"contact-admin": "Please contact your administrator to configure data sources.",
"explanation": "To visualize your data, you'll need to connect it first.",
"logo": "Logo for {{datasourceName}} data source",
"new-dashboard": "New dashboard",
"preferred": "Connect your preferred data source:",
"sampleData": "Or set up a new dashboard with sample data",
@ -314,7 +328,7 @@
},
"support-bundles": {
"subtitle": "Download support bundles",
"title": "Support Bundles"
"title": "Support bundles"
},
"teams": {
"subtitle": "Groups of users that have common dashboard and permission needs",

View File

@ -5,6 +5,21 @@
"success": "Copiado"
}
},
"command-palette": {
"action": {
"change-theme": "",
"dark-theme": "",
"light-theme": "",
"search": ""
},
"section": {
"actions": "",
"dashboard-search-results": "",
"pages": "",
"preferences": "",
"recent-dashboards": ""
}
},
"common": {
"locale": {
"default": "Por defecto"
@ -83,7 +98,6 @@
"datasource-onboarding": {
"contact-admin": "Póngase en contacto con su administrador para configurar las fuentes de datos.",
"explanation": "Para visualizar sus datos, primero tendrá que conectar una fuente.",
"logo": "Logo para la fuente de datos {{datasourceName}}",
"new-dashboard": "Nuevo panel de control",
"preferred": "Conecte su fuente de datos preferida:",
"sampleData": "O configure un nuevo panel de control con datos de muestra",

View File

@ -5,6 +5,21 @@
"success": "Copié"
}
},
"command-palette": {
"action": {
"change-theme": "",
"dark-theme": "",
"light-theme": "",
"search": ""
},
"section": {
"actions": "",
"dashboard-search-results": "",
"pages": "",
"preferences": "",
"recent-dashboards": ""
}
},
"common": {
"locale": {
"default": "Par défaut"
@ -21,7 +36,7 @@
"query-tab": "Requête",
"stats-tab": "Statistiques",
"subtitle": "{{queryCount}} requêtes avec un délai total de requête de {{formatted}}",
"title": "Inspecter : {{panelTitle}}"
"title": "Inspecter\u00a0: {{panelTitle}}"
},
"inspect-data": {
"data-options": "Options de données",
@ -51,7 +66,7 @@
"panel-json-description": "Le modèle enregistré dans le tableau de bord JSON qui configure comment tout fonctionne.",
"panel-json-label": "Panneau JSON",
"select-source": "Sélectionner la source",
"unknown": "Objet inconnu : {{show}}"
"unknown": "Objet inconnu\u00a0: {{show}}"
},
"inspect-meta": {
"no-inspector": "Pas d'inspecteur de métadonnées"
@ -83,9 +98,8 @@
"datasource-onboarding": {
"contact-admin": "Veuillez contacter votre administrateur pour configurer les sources de données.",
"explanation": "Pour visualiser vos données, vous devrez dabord les connecter.",
"logo": "Logo pour la source de données {{datasourceName}}",
"new-dashboard": "Nouveau tableau de bord",
"preferred": "Connectez votre source de données préférée :",
"preferred": "Connectez votre source de données préférée\u00a0:",
"sampleData": "Ou établissez un nouveau tableau de bord avec des exemples de données",
"viewAll": "Afficher tout",
"welcome": "Bienvenue aux tableaux de bord Grafana !"
@ -105,7 +119,7 @@
},
"library-panels": {
"save": {
"error": "Erreur lors de l'enregistrement du panneau de bibliothèque : \"{{errorMsg}}\"",
"error": "Erreur lors de l'enregistrement du panneau de bibliothèque\u00a0: \"{{errorMsg}}\"",
"success": "Panneau de bibliothèque enregistré"
}
},
@ -421,7 +435,7 @@
"info-text-1": "Un instantané est un moyen instantané de partager publiquement un tableau de bord interactif. Lors de la création, nous supprimons les données sensibles telles que les requêtes (métrique, modèle et annotation) et les liens du panneau, pour ne laisser que les métriques visibles et les noms de séries intégrés dans votre tableau de bord.",
"info-text-2": "N'oubliez pas que votre instantané <1>peut être consulté par une personne</1> qui dispose du lien et qui peut accéder à l'URL. Partagez judicieusement.",
"local-button": "Instantané local",
"mistake-message": "Avez-vous commis une erreur ? ",
"mistake-message": "Avez-vous commis une erreur\u00a0? ",
"name": "Nom de l'instantané",
"timeout": "Délai dexpiration (secondes)",
"timeout-description": "Vous devrez peut-être configurer la valeur du délai d'expiration si la collecte des métriques de votre tableau de bord prend beaucoup de temps.",

View File

@ -5,6 +5,21 @@
"success": "Cőpįęđ"
}
},
"command-palette": {
"action": {
"change-theme": "Cĥäʼnģę ŧĥęmę...",
"dark-theme": "Đäřĸ",
"light-theme": "Ŀįģĥŧ",
"search": "Ŝęäřčĥ"
},
"section": {
"actions": "Åčŧįőʼnş",
"dashboard-search-results": "Đäşĥþőäřđş",
"pages": "Päģęş",
"preferences": "Přęƒęřęʼnčęş",
"recent-dashboards": "Ŗęčęʼnŧľy vįęŵęđ đäşĥþőäřđş"
}
},
"common": {
"locale": {
"default": "Đęƒäūľŧ"
@ -83,7 +98,6 @@
"datasource-onboarding": {
"contact-admin": "Pľęäşę čőʼnŧäčŧ yőūř äđmįʼnįşŧřäŧőř ŧő čőʼnƒįģūřę đäŧä şőūřčęş.",
"explanation": "Ŧő vįşūäľįžę yőūř đäŧä, yőū'ľľ ʼnęęđ ŧő čőʼnʼnęčŧ įŧ ƒįřşŧ.",
"logo": "Ŀőģő ƒőř {{datasourceName}} đäŧä şőūřčę",
"new-dashboard": "Ńęŵ đäşĥþőäřđ",
"preferred": "Cőʼnʼnęčŧ yőūř přęƒęřřęđ đäŧä şőūřčę:",
"sampleData": "Øř şęŧ ūp ä ʼnęŵ đäşĥþőäřđ ŵįŧĥ şämpľę đäŧä",
@ -314,7 +328,7 @@
},
"support-bundles": {
"subtitle": "Đőŵʼnľőäđ şūppőřŧ þūʼnđľęş",
"title": "Ŝūppőřŧ ßūʼnđľęş"
"title": "Ŝūppőřŧ þūʼnđľęş"
},
"teams": {
"subtitle": "Ğřőūpş őƒ ūşęřş ŧĥäŧ ĥävę čőmmőʼn đäşĥþőäřđ äʼnđ pęřmįşşįőʼn ʼnęęđş",

View File

@ -5,6 +5,21 @@
"success": ""
}
},
"command-palette": {
"action": {
"change-theme": "",
"dark-theme": "",
"light-theme": "",
"search": ""
},
"section": {
"actions": "",
"dashboard-search-results": "",
"pages": "",
"preferences": "",
"recent-dashboards": ""
}
},
"common": {
"locale": {
"default": "默认"
@ -83,7 +98,6 @@
"datasource-onboarding": {
"contact-admin": "",
"explanation": "",
"logo": "",
"new-dashboard": "",
"preferred": "",
"sampleData": "",