diff --git a/public/app/features/commandPalette/actions/recentScopesActions.ts b/public/app/features/commandPalette/actions/recentScopesActions.ts
new file mode 100644
index 00000000000..2cac2372522
--- /dev/null
+++ b/public/app/features/commandPalette/actions/recentScopesActions.ts
@@ -0,0 +1,28 @@
+import { config } from '@grafana/runtime';
+import { t } from 'app/core/internationalization';
+import { defaultScopesServices } from 'app/features/scopes/ScopesContextProvider';
+
+import { CommandPaletteAction } from '../types';
+import { RECENT_SCOPES_PRIORITY } from '../values';
+
+export function getRecentScopesActions(): CommandPaletteAction[] {
+ if (!config.featureToggles.scopeFilters) {
+ return [];
+ }
+
+ const { scopesSelectorService } = defaultScopesServices();
+
+ const recentScopes = scopesSelectorService.getRecentScopes();
+
+ return recentScopes.map((recentScope) => {
+ return {
+ id: recentScope.map((scope) => scope.scope.spec.title).join(', '),
+ name: recentScope.map((scope) => scope.scope.spec.title).join(', '),
+ section: t('command-palette.section.recent-scopes', 'Recent scopes'),
+ priority: RECENT_SCOPES_PRIORITY,
+ perform: () => {
+ scopesSelectorService.changeScopes(recentScope.map((scope) => scope.scope.metadata.name));
+ },
+ };
+ });
+}
diff --git a/public/app/features/commandPalette/actions/useActions.ts b/public/app/features/commandPalette/actions/useActions.ts
index 44200cba273..5de66ff7375 100644
--- a/public/app/features/commandPalette/actions/useActions.ts
+++ b/public/app/features/commandPalette/actions/useActions.ts
@@ -5,6 +5,7 @@ import { useSelector } from 'app/types';
import { CommandPaletteAction } from '../types';
import { getRecentDashboardActions } from './dashboardActions';
+import { getRecentScopesActions } from './recentScopesActions';
import getStaticActions from './staticActions';
import useExtensionActions from './useExtensionActions';
@@ -14,6 +15,7 @@ export default function useActions(searchQuery: string) {
const extensionActions = useExtensionActions();
const navBarTree = useSelector((state) => state.navBarTree);
+ const recentScopesActions = getRecentScopesActions();
// Load standard static actions
useEffect(() => {
@@ -32,5 +34,5 @@ export default function useActions(searchQuery: string) {
}
}, [searchQuery]);
- return searchQuery ? navTreeActions : [...recentDashboardActions, ...navTreeActions];
+ return searchQuery ? navTreeActions : [...recentDashboardActions, ...navTreeActions, ...recentScopesActions];
}
diff --git a/public/app/features/commandPalette/values.ts b/public/app/features/commandPalette/values.ts
index 88abd798f19..a3bc8b24ddf 100644
--- a/public/app/features/commandPalette/values.ts
+++ b/public/app/features/commandPalette/values.ts
@@ -1,3 +1,4 @@
+export const RECENT_SCOPES_PRIORITY = 7;
export const RECENT_DASHBOARDS_PRIORITY = 6;
export const ACTIONS_PRIORITY = 5;
export const DEFAULT_PRIORITY = 4;
diff --git a/public/app/features/scopes/selector/RecentScopes.tsx b/public/app/features/scopes/selector/RecentScopes.tsx
new file mode 100644
index 00000000000..a6b7f5d236b
--- /dev/null
+++ b/public/app/features/scopes/selector/RecentScopes.tsx
@@ -0,0 +1,79 @@
+import { css } from '@emotion/css';
+import { useId, useState } from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2, Stack, Text, Icon, Box } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
+
+import { SelectedScope } from './types';
+
+interface RecentScopesProps {
+ recentScopes: SelectedScope[][];
+ onSelect: (scopes: SelectedScope[]) => void;
+}
+
+export const RecentScopes = ({ recentScopes, onSelect }: RecentScopesProps) => {
+ const styles = useStyles2(getStyles);
+ const [expanded, setExpanded] = useState(false);
+
+ const contentId = useId();
+ return (
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ recentScopeButton: css({
+ textAlign: 'left',
+ background: 'none',
+ border: 'none',
+ padding: 0,
+ cursor: 'pointer',
+ textOverflow: 'ellipsis',
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ }),
+ expandButton: css({
+ display: 'flex',
+ alignItems: 'center',
+ gap: theme.spacing(1),
+ background: 'none',
+ border: 'none',
+ padding: 0,
+ cursor: 'pointer',
+ }),
+ legend: css({
+ marginBottom: 0,
+ }),
+});
diff --git a/public/app/features/scopes/selector/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx
index 0c163fcc5bc..821311c110e 100644
--- a/public/app/features/scopes/selector/ScopesInput.tsx
+++ b/public/app/features/scopes/selector/ScopesInput.tsx
@@ -85,6 +85,7 @@ export function ScopesInput({ nodes, scopes, disabled, loading, onInputClick, on
onRemoveAllClick()}
/>
) : undefined
diff --git a/public/app/features/scopes/selector/ScopesSelector.tsx b/public/app/features/scopes/selector/ScopesSelector.tsx
index f0bface4c80..2b9bd08606b 100644
--- a/public/app/features/scopes/selector/ScopesSelector.tsx
+++ b/public/app/features/scopes/selector/ScopesSelector.tsx
@@ -34,7 +34,10 @@ export const ScopesSelector = () => {
const { nodes, loadingNodeName, selectedScopes, opened, treeScopes } = selectorServiceState;
const { scopesService, scopesSelectorService, scopesDashboardsService } = services;
const { readOnly, drawerOpened, loading } = scopes.state;
- const { open, removeAllScopes, closeAndApply, closeAndReset, updateNode, toggleNodeSelect } = scopesSelectorService;
+ const { open, removeAllScopes, closeAndApply, closeAndReset, updateNode, toggleNodeSelect, getRecentScopes } =
+ scopesSelectorService;
+
+ const recentScopes = getRecentScopes();
const dashboardsIconLabel = readOnly
? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode')
@@ -74,14 +77,21 @@ export const ScopesSelector = () => {
{loading ? (
) : (
-
+ <>
+ {
+ scopesSelectorService.changeScopes(recentScopeSet.map((s) => s.scope.metadata.name));
+ scopesSelectorService.closeAndApply();
+ }}
+ />
+ >
)}
diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts
index 966d017d4a9..8c34ddd68e7 100644
--- a/public/app/features/scopes/selector/ScopesSelectorService.ts
+++ b/public/app/features/scopes/selector/ScopesSelectorService.ts
@@ -7,6 +7,8 @@ import { getEmptyScopeObject } from '../utils';
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
+const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
+
export interface ScopesSelectorServiceState {
loading: boolean;
@@ -188,11 +190,41 @@ export class ScopesSelectorService extends ScopesServiceBase scope.metadata.name));
selectedScopes = await this.apiClient.fetchMultipleScopes(treeScopes);
+ if (selectedScopes.length > 0) {
+ this.addRecentScopes(selectedScopes);
+ }
this.updateState({ selectedScopes, loading: false });
};
public removeAllScopes = () => this.setNewScopes([]);
+ private addRecentScopes = (scopes: SelectedScope[]) => {
+ if (scopes.length === 0) {
+ return;
+ }
+
+ const RECENT_SCOPES_MAX_LENGTH = 5;
+
+ const recentScopes = this.getRecentScopes();
+ recentScopes.unshift(scopes);
+ localStorage.setItem(RECENT_SCOPES_KEY, JSON.stringify(recentScopes.slice(0, RECENT_SCOPES_MAX_LENGTH - 1)));
+ };
+
+ public getRecentScopes = (): SelectedScope[][] => {
+ const recentScopes = JSON.parse(localStorage.getItem(RECENT_SCOPES_KEY) || '[]');
+ // TODO: Make type safe
+ // Filter out the current selection from recent scopes to avoid duplicates
+ const filteredScopes = recentScopes.filter((scopes: SelectedScope[]) => {
+ if (scopes.length !== this.state.selectedScopes.length) {
+ return true;
+ }
+ const scopeSet = new Set(scopes.map((s) => s.scope.metadata.name));
+ return !this.state.selectedScopes.every((s) => scopeSet.has(s.scope.metadata.name));
+ });
+
+ return filteredScopes.map((scopes: SelectedScope[]) => scopes);
+ };
+
/**
* Opens the scopes selector drawer and loads the root nodes if they are not loaded yet.
*/
diff --git a/public/app/features/scopes/selector/ScopesTree.tsx b/public/app/features/scopes/selector/ScopesTree.tsx
index ed37df63729..0037052a90c 100644
--- a/public/app/features/scopes/selector/ScopesTree.tsx
+++ b/public/app/features/scopes/selector/ScopesTree.tsx
@@ -1,12 +1,12 @@
import { Dictionary, groupBy } from 'lodash';
import { useMemo } from 'react';
+import { RecentScopes } from './RecentScopes';
import { ScopesTreeHeadline } from './ScopesTreeHeadline';
import { ScopesTreeItem } from './ScopesTreeItem';
import { ScopesTreeLoading } from './ScopesTreeLoading';
import { ScopesTreeSearch } from './ScopesTreeSearch';
-import { Node, NodeReason, NodesMap, OnNodeSelectToggle, OnNodeUpdate, TreeScope } from './types';
-
+import { Node, NodeReason, NodesMap, OnNodeSelectToggle, OnNodeUpdate, TreeScope, SelectedScope } from './types';
export interface ScopesTreeProps {
nodes: NodesMap;
nodePath: string[];
@@ -14,6 +14,10 @@ export interface ScopesTreeProps {
scopes: TreeScope[];
onNodeUpdate: OnNodeUpdate;
onNodeSelectToggle: OnNodeSelectToggle;
+
+ // Recent scopes are only shown at the root node
+ recentScopes?: SelectedScope[][];
+ onRecentScopesSelect?: (recentScopeSet: SelectedScope[]) => void;
}
export function ScopesTree({
@@ -21,6 +25,8 @@ export function ScopesTree({
nodePath,
loadingNodeName,
scopes,
+ recentScopes,
+ onRecentScopesSelect,
onNodeUpdate,
onNodeSelectToggle,
}: ScopesTreeProps) {
@@ -41,6 +47,13 @@ export function ScopesTree({
query={node.query}
onNodeUpdate={onNodeUpdate}
/>
+ {nodePath.length === 1 &&
+ nodePath[0] === '' &&
+ !anyChildExpanded &&
+ recentScopes &&
+ recentScopes.length > 0 &&
+ onRecentScopesSelect &&
+ !node.query && }
n.nodeType === 'container') && !query)) {
return null;
}
diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts
index e430a95ab4f..bf94cafa922 100644
--- a/public/app/features/scopes/tests/selector.test.ts
+++ b/public/app/features/scopes/tests/selector.test.ts
@@ -3,8 +3,26 @@ import { config, locationService } from '@grafana/runtime';
import { getDashboardScenePageStateManager } from '../../dashboard-scene/pages/DashboardScenePageStateManager';
import { ScopesService } from '../ScopesService';
-import { applyScopes, cancelScopes, openSelector, selectResultCloud, updateScopes } from './utils/actions';
-import { expectScopesSelectorValue } from './utils/assertions';
+import {
+ applyScopes,
+ cancelScopes,
+ selectResultApplicationsMimir,
+ selectResultApplicationsGrafana,
+ openSelector,
+ selectResultCloud,
+ updateScopes,
+ expandRecentScopes,
+ expandResultApplications,
+ selectRecentScope,
+ clearSelector,
+} from './utils/actions';
+import {
+ expectRecentScope,
+ expectRecentScopeNotPresent,
+ expectRecentScopeNotPresentInDocument,
+ expectRecentScopesSection,
+ expectScopesSelectorValue,
+} from './utils/assertions';
import { getDatasource, getInstanceSettings, getMock, mocksScopes } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
import { getListOfScopes } from './utils/selectors';
@@ -33,6 +51,7 @@ describe('Selector', () => {
scopesService = result.scopesService;
fetchSelectedScopesSpy = jest.spyOn(result.client, 'fetchMultipleScopes');
dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard');
+ window.localStorage.clear();
});
afterEach(async () => {
@@ -65,4 +84,92 @@ describe('Selector', () => {
await updateScopes(scopesService, ['grafana']);
expect(dashboardReloadSpy).not.toHaveBeenCalled();
});
+
+ describe('Recent scopes', () => {
+ it('Recent scopes should appear after selecting a second set of scopes', async () => {
+ await openSelector();
+ await expandResultApplications();
+ await selectResultApplicationsGrafana();
+ await applyScopes();
+
+ await openSelector();
+ await selectResultApplicationsMimir();
+ await applyScopes();
+
+ // Grafana,Mimir currently selected. Grafana is the first recent scope.
+ await openSelector();
+ expectRecentScopesSection();
+ await expandRecentScopes();
+ expectRecentScope('Grafana');
+ expectRecentScopeNotPresent('Mimir');
+ expectRecentScopeNotPresent('Grafana, Mimir');
+ await selectRecentScope('Grafana');
+
+ expectScopesSelectorValue('Grafana');
+
+ await openSelector();
+ await expandRecentScopes();
+ expectRecentScope('Grafana, Mimir');
+ expectRecentScopeNotPresent('Grafana');
+ expectRecentScopeNotPresent('Mimir');
+ await selectRecentScope('Grafana, Mimir');
+
+ expectScopesSelectorValue('Grafana, Mimir');
+ });
+
+ it('recent scopes should not be visible when the first scope is selected', async () => {
+ await openSelector();
+ await expandResultApplications();
+ await selectResultApplicationsGrafana();
+ await applyScopes();
+
+ await openSelector();
+ expectRecentScopeNotPresentInDocument();
+ });
+
+ it('should not show recent scopes when no scopes have been previously selected', async () => {
+ await openSelector();
+ expectRecentScopeNotPresentInDocument();
+ });
+
+ it('should maintain recent scopes after deselecting all scopes', async () => {
+ // First select some scopes
+ await openSelector();
+ await expandResultApplications();
+ await selectResultApplicationsGrafana();
+ await selectResultApplicationsMimir();
+ await applyScopes();
+
+ // Deselect all scopes
+ await clearSelector();
+
+ // Recent scopes should still be available
+ await openSelector();
+ expectRecentScopesSection();
+ await expandRecentScopes();
+ expectRecentScope('Grafana, Mimir');
+ });
+
+ it('should update recent scopes when selecting a different combination', async () => {
+ // First select Grafana + Mimir
+ await openSelector();
+ await expandResultApplications();
+ await selectResultApplicationsGrafana();
+ await selectResultApplicationsMimir();
+ await applyScopes();
+
+ // Then select just Grafana
+ await openSelector();
+ await selectResultApplicationsMimir();
+ await applyScopes();
+
+ await clearSelector();
+
+ // Check recent scopes are updated
+ await openSelector();
+ await expandRecentScopes();
+ expectRecentScope('Grafana, Mimir');
+ expectRecentScope('Grafana');
+ });
+ });
});
diff --git a/public/app/features/scopes/tests/tree.test.ts b/public/app/features/scopes/tests/tree.test.ts
index 51ebbc35219..c630eb9bd70 100644
--- a/public/app/features/scopes/tests/tree.test.ts
+++ b/public/app/features/scopes/tests/tree.test.ts
@@ -249,7 +249,6 @@ describe('Tree', () => {
it('Shows the proper headline', async () => {
await openSelector();
- expectScopesHeadline('Recommended');
await searchScopes('Applications');
expect(fetchNodesSpy).toHaveBeenCalledTimes(2);
@@ -260,6 +259,13 @@ describe('Tree', () => {
expectScopesHeadline('No results found for your query');
});
+ it('Should only show Recommended when there are no leaf container nodes visible', async () => {
+ await openSelector();
+ await expandResultApplications();
+ await expandResultApplicationsCloud();
+ expectScopesHeadline('Recommended');
+ });
+
it('Updates the paths for scopes without paths on nodes fetching', async () => {
const selectedScopeName = 'grafana';
const unselectedScopeName = 'mimir';
diff --git a/public/app/features/scopes/tests/utils/actions.ts b/public/app/features/scopes/tests/utils/actions.ts
index 53e71f5a492..c5c91727804 100644
--- a/public/app/features/scopes/tests/utils/actions.ts
+++ b/public/app/features/scopes/tests/utils/actions.ts
@@ -12,7 +12,10 @@ import {
getDashboardsExpand,
getDashboardsSearch,
getNotFoundForFilterClear,
+ getPersistedApplicationsGrafanaSelect,
getPersistedApplicationsMimirSelect,
+ getRecentScopeSet,
+ getRecentScopesSection,
getResultApplicationsCloudDevSelect,
getResultApplicationsCloudExpand,
getResultApplicationsCloudSelect,
@@ -25,6 +28,7 @@ import {
getResultCloudSelect,
getSelectorApply,
getSelectorCancel,
+ getSelectorClear,
getSelectorInput,
getTreeSearch,
} from './selectors';
@@ -38,6 +42,7 @@ const type = async (selector: () => HTMLInputElement, value: string) => {
export const updateScopes = async (service: ScopesService, scopes: string[]) =>
act(async () => service.changeScopes(scopes));
export const openSelector = async () => click(getSelectorInput);
+export const clearSelector = async () => click(getSelectorClear);
export const applyScopes = async () => {
await click(getSelectorApply);
await jest.runOnlyPendingTimersAsync();
@@ -45,11 +50,14 @@ export const applyScopes = async () => {
export const cancelScopes = async () => click(getSelectorCancel);
export const searchScopes = async (value: string) => type(getTreeSearch, value);
export const clearScopesSearch = async () => type(getTreeSearch, '');
+export const expandRecentScopes = async () => click(getRecentScopesSection);
export const expandResultApplications = async () => click(getResultApplicationsExpand);
export const expandResultApplicationsCloud = async () => click(getResultApplicationsCloudExpand);
export const expandResultCloud = async () => click(getResultCloudExpand);
+export const selectRecentScope = async (scope: string) => click(() => getRecentScopeSet(scope));
export const selectResultApplicationsGrafana = async () => click(getResultApplicationsGrafanaSelect);
export const selectPersistedApplicationsMimir = async () => click(getPersistedApplicationsMimirSelect);
+export const selectPersistedApplicationsGrafana = async () => click(getPersistedApplicationsGrafanaSelect);
export const selectResultApplicationsMimir = async () => click(getResultApplicationsMimirSelect);
export const selectResultApplicationsCloud = async () => click(getResultApplicationsCloudSelect);
export const selectResultApplicationsCloudDev = async () => click(getResultApplicationsCloudDevSelect);
diff --git a/public/app/features/scopes/tests/utils/assertions.ts b/public/app/features/scopes/tests/utils/assertions.ts
index 4f03ce058f8..f9425179988 100644
--- a/public/app/features/scopes/tests/utils/assertions.ts
+++ b/public/app/features/scopes/tests/utils/assertions.ts
@@ -9,6 +9,8 @@ import {
getNotFoundForScope,
getNotFoundNoScopes,
getPersistedApplicationsMimirSelect,
+ getRecentScopeSet,
+ getRecentScopesSection,
getResultApplicationsCloudSelect,
getResultApplicationsGrafanaSelect,
getResultApplicationsMimirSelect,
@@ -25,6 +27,8 @@ import {
queryDashboardsSearch,
queryPersistedApplicationsGrafanaSelect,
queryPersistedApplicationsMimirSelect,
+ queryRecentScopeSet,
+ queryRecentScopesSection,
queryResultApplicationsCloudSelect,
queryResultApplicationsGrafanaSelect,
queryResultApplicationsMimirSelect,
@@ -40,6 +44,10 @@ const expectValue = (selector: () => HTMLInputElement, value: string) => expect(
const expectTextContent = (selector: () => HTMLElement, text: string) => expect(selector()).toHaveTextContent(text);
const expectDisabled = (selector: () => HTMLElement) => expect(selector()).toBeDisabled();
+export const expectRecentScopeNotPresent = (scope: string) => expectNotInDocument(() => queryRecentScopeSet(scope));
+export const expectRecentScope = (scope: string) => expectInDocument(() => getRecentScopeSet(scope));
+export const expectRecentScopeNotPresentInDocument = () => expectNotInDocument(queryRecentScopesSection);
+export const expectRecentScopesSection = () => expectInDocument(getRecentScopesSection);
export const expectScopesSelectorClosed = () => expectNotInDocument(querySelectorApply);
export const expectScopesSelectorDisabled = () => expectDisabled(getSelectorInput);
export const expectScopesSelectorValue = (value: string) => expectValue(getSelectorInput, value);
diff --git a/public/app/features/scopes/tests/utils/selectors.ts b/public/app/features/scopes/tests/utils/selectors.ts
index a08a6928b1b..79d0b7ca3d2 100644
--- a/public/app/features/scopes/tests/utils/selectors.ts
+++ b/public/app/features/scopes/tests/utils/selectors.ts
@@ -5,6 +5,7 @@ import { ScopesSelectorService } from '../../selector/ScopesSelectorService';
const selectors = {
tree: {
+ recentScopesSection: 'scopes-selector-recent-scopes-section',
search: 'scopes-tree-search',
headline: 'scopes-tree-headline',
select: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-checkbox`,
@@ -18,6 +19,7 @@ const selectors = {
loading: 'scopes-selector-loading',
apply: 'scopes-selector-apply',
cancel: 'scopes-selector-cancel',
+ clear: 'scopes-selector-input-clear',
},
dashboards: {
expand: 'scopes-dashboards-expand',
@@ -34,10 +36,16 @@ const selectors = {
};
export const getSelectorInput = () => screen.getByTestId(selectors.selector.input);
+export const getSelectorClear = () => screen.getByTestId(selectors.selector.clear);
export const querySelectorApply = () => screen.queryByTestId(selectors.selector.apply);
export const getSelectorApply = () => screen.getByTestId(selectors.selector.apply);
export const getSelectorCancel = () => screen.getByTestId(selectors.selector.cancel);
+export const getRecentScopesSection = () => screen.getByTestId(selectors.tree.recentScopesSection);
+export const queryRecentScopesSection = () => screen.queryByTestId(selectors.tree.recentScopesSection);
+export const getRecentScopeSet = (scope: string) => screen.getByRole('button', { name: scope });
+export const queryRecentScopeSet = (scope: string) => screen.queryByRole('button', { name: scope });
+
export const getDashboardsExpand = () => screen.getByTestId(selectors.dashboards.expand);
export const getDashboardsContainer = () => screen.getByTestId(selectors.dashboards.container);
export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container);
@@ -63,6 +71,8 @@ export const getResultApplicationsGrafanaSelect = () =>
screen.getByTestId(selectors.tree.select('applications-grafana', 'result'));
export const queryPersistedApplicationsGrafanaSelect = () =>
screen.queryByTestId(selectors.tree.select('applications-grafana', 'persisted'));
+export const getPersistedApplicationsGrafanaSelect = () =>
+ screen.getByTestId(selectors.tree.select('applications-grafana', 'persisted'));
export const queryResultApplicationsMimirSelect = () =>
screen.queryByTestId(selectors.tree.select('applications-mimir', 'result'));
export const getResultApplicationsMimirSelect = () =>
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 21154720720..f6775796ce5 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -2719,7 +2719,8 @@
"folder-search-results": "Folders",
"pages": "Pages",
"preferences": "Preferences",
- "recent-dashboards": "Recent dashboards"
+ "recent-dashboards": "Recent dashboards",
+ "recent-scopes": "Recent scopes"
}
},
"common": {