RecentlyViewedDashboards: Set up container on browsing dashboards page (#115164)

* RecentlyViewedDashboards: Set up container on browsing dashboards page
This commit is contained in:
Yunwen Zheng
2025-12-12 10:32:05 -05:00
committed by GitHub
parent 5f80a29a28
commit 584615cf3f
5 changed files with 122 additions and 15 deletions
@@ -27,6 +27,7 @@ import { BrowseFilters } from './components/BrowseFilters';
import { BrowseView } from './components/BrowseView';
import CreateNewButton from './components/CreateNewButton';
import { FolderActionsButton } from './components/FolderActionsButton';
import { RecentlyViewedDashboards } from './components/RecentlyViewedDashboards';
import { SearchView } from './components/SearchView';
import { getFolderPermissions } from './permissions';
import { useHasSelection } from './state/hooks';
@@ -178,6 +179,8 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
>
<Page.Contents className={styles.pageContents}>
<ProvisionedFolderPreviewBanner queryParams={queryParams} />
{/* only show recently viewed dashboards when in root */}
{!folderUID && <RecentlyViewedDashboards />}
<div>
<FilterInput
placeholder={getSearchPlaceholder(searchState.includePanels)}
@@ -0,0 +1,77 @@
import { css } from '@emotion/css';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
import { CollapsableSection, Link, Spinner, Text, useStyles2 } from '@grafana/ui';
import { getRecentlyViewedDashboards } from './utils';
const MAX_RECENT = 5;
export function RecentlyViewedDashboards() {
const styles = useStyles2(getStyles);
const { value: recentDashboards = [], loading } = useAsync(async () => {
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
return [];
}
return getRecentlyViewedDashboards(MAX_RECENT);
}, []);
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
return null;
}
return (
<CollapsableSection
headerDataTestId="browseDashboardsRecentlyViewedTitle"
label={
<Text variant="h5" element="h3">
<Trans i18nKey="browse-dashboards.recently-viewed.title">Recently viewed</Trans>
</Text>
}
isOpen={true}
className={styles.title}
contentClassName={styles.content}
>
{/* placeholder */}
{loading && <Spinner />}
{/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */}
{!loading && recentDashboards.length === 0 && (
<Text>{t('browse-dashboards.recently-viewed.empty', 'Nothing viewed yet')}</Text>
)}
{/* TODO: implement actual card content */}
{!loading && recentDashboards.length > 0 && (
<>
{recentDashboards.map((dash) => (
<div key={dash.uid}>
<Link href={dash.url}>{dash.name}</Link>
</div>
))}
</>
)}
</CollapsableSection>
);
}
const getStyles = (theme: GrafanaTheme2) => {
const accent = theme.visualization.getColorByName('purple'); // or your own hex
return {
title: css({
background: `linear-gradient(90deg, ${accent} 0%, #e478eaff 100%)`,
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
color: 'transparent',
'& button svg': {
color: accent,
},
}),
content: css({
paddingTop: theme.spacing(0),
}),
};
};
@@ -1,6 +1,9 @@
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import impressionSrv from 'app/core/services/impression_srv';
import { ResourceRef } from 'app/features/provisioning/components/BulkActions/useBulkActionJob';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardQueryResult } from 'app/features/search/service/types';
import { DashboardTreeSelection, DashboardViewItemWithUIItems, BrowseDashboardsPermissions } from '../types';
@@ -60,3 +63,36 @@ export function canSelectItems(permissions: BrowseDashboardsPermissions) {
const canSelectDashboards = canEditDashboards || canDeleteDashboards;
return Boolean(canSelectFolders || canSelectDashboards);
}
/**
* Returns dashboard search results ordered the same way the user opened them.
*/
export async function getRecentlyViewedDashboards(maxItems = 5): Promise<DashboardQueryResult[]> {
try {
const recentlyOpened = (await impressionSrv.getDashboardOpened()).slice(0, maxItems);
if (!recentlyOpened.length) {
return [];
}
const searchResults = await getGrafanaSearcher().search({
kind: ['dashboard'],
limit: recentlyOpened.length,
uid: recentlyOpened,
});
const dashboards = searchResults.view.toArray();
// Keep dashboards in the same order the user opened them.
// When a UID is missing from the search response
// push it to the end instead of letting indexOf return -1
const order = (uid: string) => {
const idx = recentlyOpened.indexOf(uid);
return idx === -1 ? recentlyOpened.length : idx;
};
dashboards.sort((a, b) => order(a.uid) - order(b.uid));
return dashboards;
} catch (error) {
console.error('Failed to load recently viewed dashboards', error);
return [];
}
}
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import impressionSrv from 'app/core/services/impression_srv';
import { getRecentlyViewedDashboards } from 'app/features/browse-dashboards/components/utils';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { CommandPaletteAction } from '../types';
@@ -20,20 +20,7 @@ export async function getRecentDashboardActions(): Promise<CommandPaletteAction[
return [];
}
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 recentResults = await getRecentlyViewedDashboards(MAX_RECENT_DASHBOARDS);
const recentDashboardActions: CommandPaletteAction[] = recentResults.map((item) => {
const { url, name } = item; // items are backed by DataFrameView, so must hold the url in a closure
+4
View File
@@ -3707,6 +3707,10 @@
"clear": "Clear search and filters",
"text": "No results found for your query"
},
"recently-viewed": {
"empty": "Nothing viewed yet",
"title": "Recently viewed"
},
"restore": {
"all-failed_one": "Failed to restore {{count}} dashboard",
"all-failed_other": "Failed to restore {{count}} dashboards",