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 ( +
+ + + + + + {expanded && + recentScopes.map((recentScopeSet) => ( + + ))} + + +
+ ); +}; + +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": {