diff --git a/packages/grafana-data/src/types/scopes.ts b/packages/grafana-data/src/types/scopes.ts index 7c2b9fc01aa..86e6ca33ee0 100644 --- a/packages/grafana-data/src/types/scopes.ts +++ b/packages/grafana-data/src/types/scopes.ts @@ -2,6 +2,7 @@ export interface ScopeDashboardBindingSpec { dashboard: string; dashboardTitle: string; scope: string; + groups?: string[]; } // TODO: Use Resource from apiserver when we export the types diff --git a/public/app/features/scopes/ScopesFacadeScene.ts b/public/app/features/scopes/ScopesFacadeScene.ts index db65da5f7a8..268634bc5f3 100644 --- a/public/app/features/scopes/ScopesFacadeScene.ts +++ b/public/app/features/scopes/ScopesFacadeScene.ts @@ -1,3 +1,5 @@ +import { isEqual } from 'lodash'; + import { SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { scopesSelectorScene } from './instance'; @@ -20,7 +22,7 @@ export class ScopesFacade extends SceneObjectBase { this._subs.add( scopesSelectorScene?.subscribeToState((newState, prevState) => { - if (!newState.isLoadingScopes && (prevState.isLoadingScopes || newState.scopes !== prevState.scopes)) { + if (!newState.isLoadingScopes && (prevState.isLoadingScopes || !isEqual(newState.scopes, prevState.scopes))) { this.state.handler?.(this); } }) diff --git a/public/app/features/scopes/internal/ScopesDashboardsScene.tsx b/public/app/features/scopes/internal/ScopesDashboardsScene.tsx index 510f589804b..e838286df3f 100644 --- a/public/app/features/scopes/internal/ScopesDashboardsScene.tsx +++ b/public/app/features/scopes/internal/ScopesDashboardsScene.tsx @@ -1,23 +1,27 @@ import { css, cx } from '@emotion/css'; import { isEqual } from 'lodash'; -import { Link } from 'react-router-dom'; -import { GrafanaTheme2, urlUtil } from '@grafana/data'; +import { GrafanaTheme2, ScopeDashboardBinding } from '@grafana/data'; import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState } from '@grafana/scenes'; -import { Button, CustomScrollbar, FilterInput, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { Button, CustomScrollbar, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; +import { ScopesDashboardsTree } from './ScopesDashboardsTree'; +import { ScopesDashboardsTreeSearch } from './ScopesDashboardsTreeSearch'; import { ScopesSelectorScene } from './ScopesSelectorScene'; -import { fetchSuggestedDashboards } from './api'; +import { fetchDashboards } from './api'; import { DASHBOARDS_OPENED_KEY } from './const'; -import { SuggestedDashboard } from './types'; -import { getScopeNamesFromSelectedScopes } from './utils'; +import { SuggestedDashboardsFoldersMap } from './types'; +import { filterFolders, getScopeNamesFromSelectedScopes, groupDashboards } from './utils'; export interface ScopesDashboardsSceneState extends SceneObjectState { selector: SceneObjectRef | null; - dashboards: SuggestedDashboard[]; - filteredDashboards: SuggestedDashboard[]; + // by keeping a track of the raw response, it's much easier to check if we got any dashboards for the currently selected scopes + dashboards: ScopeDashboardBinding[]; + // this is a grouping in folders of the `dashboards` property. it is used for filtering the dashboards and folders when the search query changes + folders: SuggestedDashboardsFoldersMap; + // a filtered version of the `folders` property. this prevents a lot of unnecessary parsings in React renders + filteredFolders: SuggestedDashboardsFoldersMap; forScopeNames: string[]; isLoading: boolean; isPanelOpened: boolean; @@ -28,7 +32,8 @@ export interface ScopesDashboardsSceneState extends SceneObjectState { export const getInitialDashboardsState: () => Omit = () => ({ dashboards: [], - filteredDashboards: [], + folders: {}, + filteredFolders: {}, forScopeNames: [], isLoading: false, isPanelOpened: localStorage.getItem(DASHBOARDS_OPENED_KEY) === 'true', @@ -80,7 +85,8 @@ export class ScopesDashboardsScene extends SceneObjectBase 0, @@ -101,14 +110,35 @@ export class ScopesDashboardsScene extends SceneObjectBase dashboardTitle.toLowerCase().includes(lowerCasedSearchQuery)); - } } export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps) { - const { dashboards, filteredDashboards, isLoading, isPanelOpened, isEnabled, searchQuery, scopesSelected } = + const { dashboards, filteredFolders, isLoading, isPanelOpened, isEnabled, searchQuery, scopesSelected } = model.useState(); - const styles = useStyles2(getStyles); - const [queryParams] = useQueryParams(); + const styles = useStyles2(getStyles); if (!isEnabled || !isPanelOpened) { return null; @@ -178,15 +201,11 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps -
- model.changeSearchQuery(value)} - /> -
+ model.changeSearchQuery(value)} + /> {isLoading ? ( - ) : filteredDashboards.length > 0 ? ( + ) : filteredFolders[''] ? ( - {filteredDashboards.map(({ dashboard, dashboardTitle }) => ( - - {dashboardTitle} - - ))} + model.updateFolder(path, isExpanded)} + /> ) : (

@@ -246,19 +260,8 @@ const getStyles = (theme: GrafanaTheme2) => { margin: 0, textAlign: 'center', }), - searchInputContainer: css({ - flex: '0 1 auto', - }), loadingIndicator: css({ alignSelf: 'center', }), - dashboardItem: css({ - padding: theme.spacing(1, 0), - borderBottom: `1px solid ${theme.colors.border.weak}`, - - '& :is(:first-child)': { - paddingTop: 0, - }, - }), }; }; diff --git a/public/app/features/scopes/internal/ScopesDashboardsTree.tsx b/public/app/features/scopes/internal/ScopesDashboardsTree.tsx new file mode 100644 index 00000000000..c90ce0fd729 --- /dev/null +++ b/public/app/features/scopes/internal/ScopesDashboardsTree.tsx @@ -0,0 +1,32 @@ +import { ScopesDashboardsTreeDashboardItem } from './ScopesDashboardsTreeDashboardItem'; +import { ScopesDashboardsTreeFolderItem } from './ScopesDashboardsTreeFolderItem'; +import { OnFolderUpdate, SuggestedDashboardsFoldersMap } from './types'; + +export interface ScopesDashboardsTreeProps { + folders: SuggestedDashboardsFoldersMap; + folderPath: string[]; + onFolderUpdate: OnFolderUpdate; +} + +export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) { + const folderId = folderPath[folderPath.length - 1]; + const folder = folders[folderId]; + + return ( +

+ {Object.entries(folder.folders).map(([subFolderId, subFolder]) => ( + + ))} + + {Object.values(folder.dashboards).map((dashboard) => ( + + ))} +
+ ); +} diff --git a/public/app/features/scopes/internal/ScopesDashboardsTreeDashboardItem.tsx b/public/app/features/scopes/internal/ScopesDashboardsTreeDashboardItem.tsx new file mode 100644 index 00000000000..94fe444561a --- /dev/null +++ b/public/app/features/scopes/internal/ScopesDashboardsTreeDashboardItem.tsx @@ -0,0 +1,45 @@ +import { css } from '@emotion/css'; +import { Link } from 'react-router-dom'; + +import { GrafanaTheme2, urlUtil } from '@grafana/data'; +import { Icon, useStyles2 } from '@grafana/ui'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; + +import { SuggestedDashboard } from './types'; + +export interface ScopesDashboardsTreeDashboardItemProps { + dashboard: SuggestedDashboard; +} + +export function ScopesDashboardsTreeDashboardItem({ dashboard }: ScopesDashboardsTreeDashboardItemProps) { + const styles = useStyles2(getStyles); + + const [queryParams] = useQueryParams(); + + return ( + + {dashboard.dashboardTitle} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(0.5, 0), + + '&:last-child': css({ + paddingBottom: 0, + }), + }), + }; +}; diff --git a/public/app/features/scopes/internal/ScopesDashboardsTreeFolderItem.tsx b/public/app/features/scopes/internal/ScopesDashboardsTreeFolderItem.tsx new file mode 100644 index 00000000000..e097fc7e266 --- /dev/null +++ b/public/app/features/scopes/internal/ScopesDashboardsTreeFolderItem.tsx @@ -0,0 +1,71 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { ScopesDashboardsTree } from './ScopesDashboardsTree'; +import { OnFolderUpdate, SuggestedDashboardsFolder, SuggestedDashboardsFoldersMap } from './types'; + +export interface ScopesDashboardsTreeFolderItemProps { + folder: SuggestedDashboardsFolder; + folderPath: string[]; + folders: SuggestedDashboardsFoldersMap; + onFolderUpdate: OnFolderUpdate; +} + +export function ScopesDashboardsTreeFolderItem({ + folder, + folderPath, + folders, + onFolderUpdate, +}: ScopesDashboardsTreeFolderItemProps) { + const styles = useStyles2(getStyles); + + return ( +
+ + + {folder.isExpanded && ( +
+ +
+ )} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + flexDirection: 'column', + padding: theme.spacing(0.5, 0), + }), + expand: css({ + alignItems: 'center', + background: 'none', + border: 0, + display: 'flex', + gap: theme.spacing(1), + margin: 0, + padding: 0, + }), + children: css({ + paddingLeft: theme.spacing(4), + }), + }; +}; diff --git a/public/app/features/scopes/internal/ScopesDashboardsTreeSearch.tsx b/public/app/features/scopes/internal/ScopesDashboardsTreeSearch.tsx new file mode 100644 index 00000000000..d9b971642a5 --- /dev/null +++ b/public/app/features/scopes/internal/ScopesDashboardsTreeSearch.tsx @@ -0,0 +1,55 @@ +import { css } from '@emotion/css'; +import { useEffect, useState } from 'react'; +import { useDebounce } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { FilterInput, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +export interface ScopesDashboardsTreeSearchProps { + disabled: boolean; + query: string; + onChange: (value: string) => void; +} + +export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: ScopesDashboardsTreeSearchProps) { + const styles = useStyles2(getStyles); + + const [inputState, setInputState] = useState<{ value: string; isDirty: boolean }>({ value: query, isDirty: false }); + + const [getDebounceState] = useDebounce( + () => { + if (inputState.isDirty) { + onChange(inputState.value); + } + }, + 500, + [inputState.isDirty, inputState.value] + ); + + useEffect(() => { + if ((getDebounceState() || !inputState.isDirty) && inputState.value !== query) { + setInputState({ value: query, isDirty: false }); + } + }, [getDebounceState, inputState, query]); + + return ( +
+ setInputState({ value, isDirty: true })} + /> +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + flex: '0 1 auto', + }), + }; +}; diff --git a/public/app/features/scopes/internal/api.ts b/public/app/features/scopes/internal/api.ts index 634de927253..81eb52d48c9 100644 --- a/public/app/features/scopes/internal/api.ts +++ b/public/app/features/scopes/internal/api.ts @@ -2,7 +2,7 @@ import { Scope, ScopeDashboardBinding, ScopeNode, ScopeSpec } from '@grafana/dat import { config, getBackendSrv } from '@grafana/runtime'; import { ScopedResourceClient } from 'app/features/apiserver/client'; -import { NodeReason, NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types'; +import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types'; import { getBasicScope, mergeScopes } from './utils'; const group = 'scope.grafana.app'; @@ -98,23 +98,3 @@ export async function fetchDashboards(scopeNames: string[]): Promise { - const items = await fetchDashboards(scopeNames); - - return Object.values( - items.reduce>((acc, item) => { - if (!acc[item.spec.dashboard]) { - acc[item.spec.dashboard] = { - dashboard: item.spec.dashboard, - dashboardTitle: item.spec.dashboardTitle, - items: [], - }; - } - - acc[item.spec.dashboard].items.push(item); - - return acc; - }, {}) - ); -} diff --git a/public/app/features/scopes/internal/types.ts b/public/app/features/scopes/internal/types.ts index 40d3cbbefae..ffa200ad18c 100644 --- a/public/app/features/scopes/internal/types.ts +++ b/public/app/features/scopes/internal/types.ts @@ -33,5 +33,16 @@ export interface SuggestedDashboard { items: ScopeDashboardBinding[]; } +export interface SuggestedDashboardsFolder { + title: string; + isExpanded: boolean; + folders: SuggestedDashboardsFoldersMap; + dashboards: SuggestedDashboardsMap; +} + +export type SuggestedDashboardsMap = Record; +export type SuggestedDashboardsFoldersMap = Record; + export type OnNodeUpdate = (path: string[], isExpanded: boolean, query: string) => void; export type OnNodeSelectToggle = (path: string[]) => void; +export type OnFolderUpdate = (path: string[], isExpanded: boolean) => void; diff --git a/public/app/features/scopes/internal/utils.ts b/public/app/features/scopes/internal/utils.ts index 5052469ac48..95bb29ff13b 100644 --- a/public/app/features/scopes/internal/utils.ts +++ b/public/app/features/scopes/internal/utils.ts @@ -1,6 +1,6 @@ -import { Scope } from '@grafana/data'; +import { Scope, ScopeDashboardBinding } from '@grafana/data'; -import { SelectedScope, TreeScope } from './types'; +import { SelectedScope, SuggestedDashboardsFoldersMap, TreeScope } from './types'; export function getBasicScope(name: string): Scope { return { @@ -43,3 +43,82 @@ export function getScopesFromSelectedScopes(scopes: SelectedScope[]): Scope[] { export function getScopeNamesFromSelectedScopes(scopes: SelectedScope[]): string[] { return scopes.map(({ scope }) => scope.metadata.name); } + +export function groupDashboards(dashboards: ScopeDashboardBinding[]): SuggestedDashboardsFoldersMap { + return dashboards.reduce( + (acc, dashboard) => { + const rootNode = acc['']; + const groups = dashboard.spec.groups ?? []; + + groups.forEach((group) => { + if (group && !rootNode.folders[group]) { + rootNode.folders[group] = { + title: group, + isExpanded: false, + folders: {}, + dashboards: {}, + }; + } + }); + + const targets = + groups.length > 0 + ? groups.map((group) => (group === '' ? rootNode.dashboards : rootNode.folders[group].dashboards)) + : [rootNode.dashboards]; + + targets.forEach((target) => { + if (!target[dashboard.spec.dashboard]) { + target[dashboard.spec.dashboard] = { + dashboard: dashboard.spec.dashboard, + dashboardTitle: dashboard.spec.dashboardTitle, + items: [], + }; + } + + target[dashboard.spec.dashboard].items.push(dashboard); + }); + + return acc; + }, + { + '': { + title: '', + isExpanded: true, + folders: {}, + dashboards: {}, + }, + } + ); +} + +export function filterFolders(folders: SuggestedDashboardsFoldersMap, query: string): SuggestedDashboardsFoldersMap { + query = (query ?? '').toLowerCase(); + + return Object.entries(folders).reduce((acc, [folderId, folder]) => { + // If folder matches the query, we show everything inside + if (folder.title.toLowerCase().includes(query)) { + acc[folderId] = { + ...folder, + isExpanded: true, + }; + + return acc; + } + + const filteredFolders = filterFolders(folder.folders, query); + const filteredDashboards = Object.entries(folder.dashboards).filter(([_, dashboard]) => + dashboard.dashboardTitle.toLowerCase().includes(query) + ); + + if (Object.keys(filteredFolders).length > 0 || filteredDashboards.length > 0) { + acc[folderId] = { + ...folder, + isExpanded: true, + folders: filteredFolders, + dashboards: Object.fromEntries(filteredDashboards), + }; + } + + return acc; + }, {}); +} diff --git a/public/app/features/scopes/scopes.test.tsx b/public/app/features/scopes/scopes.test.tsx deleted file mode 100644 index 50e014c53a6..00000000000 --- a/public/app/features/scopes/scopes.test.tsx +++ /dev/null @@ -1,634 +0,0 @@ -import { act, cleanup, waitFor } from '@testing-library/react'; -import userEvents from '@testing-library/user-event'; - -import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; -import { config, locationService, setPluginImportUtils } from '@grafana/runtime'; -import { sceneGraph } from '@grafana/scenes'; -import { getDashboardAPI, setDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; -import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; - -import { initializeScopes, scopesDashboardsScene, scopesSelectorScene } from './instance'; -import { - buildTestScene, - fetchNodesSpy, - fetchScopeSpy, - fetchSelectedScopesSpy, - fetchSuggestedDashboardsSpy, - getDashboard, - getDashboardsExpand, - getDashboardsSearch, - getMock, - getNotFoundForFilter, - getNotFoundForFilterClear, - getNotFoundForScope, - getNotFoundNoScopes, - getPersistedApplicationsSlothPictureFactorySelect, - getPersistedApplicationsSlothPictureFactoryTitle, - getPersistedApplicationsSlothVoteTrackerTitle, - getResultApplicationsClustersExpand, - getResultApplicationsClustersSelect, - getResultApplicationsClustersSlothClusterNorthSelect, - getResultApplicationsClustersSlothClusterSouthSelect, - getResultApplicationsExpand, - getResultApplicationsSlothPictureFactorySelect, - getResultApplicationsSlothPictureFactoryTitle, - getResultApplicationsSlothVoteTrackerSelect, - getResultApplicationsSlothVoteTrackerTitle, - getResultClustersExpand, - getResultClustersSelect, - getResultClustersSlothClusterEastRadio, - getResultClustersSlothClusterNorthRadio, - getResultClustersSlothClusterSouthRadio, - getSelectorApply, - getSelectorCancel, - getSelectorInput, - getTreeHeadline, - getTreeSearch, - mocksScopes, - queryAllDashboard, - queryDashboard, - queryDashboardsContainer, - queryDashboardsSearch, - queryPersistedApplicationsSlothPictureFactoryTitle, - queryPersistedApplicationsSlothVoteTrackerTitle, - queryResultApplicationsClustersTitle, - queryResultApplicationsSlothPictureFactoryTitle, - queryResultApplicationsSlothVoteTrackerTitle, - querySelectorApply, - renderDashboard, - resetScenes, -} from './testUtils'; -import { getClosestScopesFacade } from './utils'; - -jest.mock('@grafana/runtime', () => ({ - __esModule: true, - ...jest.requireActual('@grafana/runtime'), - useChromeHeaderHeight: jest.fn(), - getBackendSrv: () => ({ - get: getMock, - }), - usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), -})); - -const panelPlugin = getPanelPlugin({ - id: 'table', - skipDataQuery: true, -}); - -config.panels['table'] = panelPlugin.meta; - -setPluginImportUtils({ - importPanelPlugin: (id: string) => Promise.resolve(panelPlugin), - getPanelPluginFromCache: (id: string) => undefined, -}); - -describe('Scopes', () => { - describe('Feature flag off', () => { - beforeAll(() => { - config.featureToggles.scopeFilters = false; - config.featureToggles.groupByVariable = true; - - initializeScopes(); - }); - - it('Does not initialize', () => { - const dashboardScene = buildTestScene(); - dashboardScene.activate(); - expect(scopesSelectorScene).toBeNull(); - }); - }); - - describe('Feature flag on', () => { - let dashboardScene: DashboardScene; - - beforeAll(() => { - config.featureToggles.scopeFilters = true; - config.featureToggles.groupByVariable = true; - }); - - beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(jest.fn()); - - fetchNodesSpy.mockClear(); - fetchScopeSpy.mockClear(); - fetchSelectedScopesSpy.mockClear(); - fetchSuggestedDashboardsSpy.mockClear(); - getMock.mockClear(); - - initializeScopes(); - - dashboardScene = buildTestScene(); - - renderDashboard(dashboardScene); - }); - - afterEach(() => { - resetScenes(); - cleanup(); - }); - - describe('Tree', () => { - it('Navigates through scopes nodes', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsClustersExpand()); - await userEvents.click(getResultApplicationsExpand()); - }); - - it('Fetches scope details on select', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); - await waitFor(() => expect(fetchScopeSpy).toHaveBeenCalledTimes(1)); - }); - - it('Selects the proper scopes', async () => { - await act(async () => - scopesSelectorScene?.updateScopes([ - { scopeName: 'slothPictureFactory', path: [] }, - { scopeName: 'slothVoteTracker', path: [] }, - ]) - ); - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - expect(getResultApplicationsSlothVoteTrackerSelect()).toBeChecked(); - expect(getResultApplicationsSlothPictureFactorySelect()).toBeChecked(); - }); - - it('Can select scopes from same level', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getResultApplicationsClustersSelect()); - await userEvents.click(getSelectorApply()); - expect(getSelectorInput().value).toBe('slothVoteTracker, slothPictureFactory, Cluster Index Helper'); - }); - - it('Can select a node from an inner level', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getResultApplicationsClustersExpand()); - await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect()); - await userEvents.click(getSelectorApply()); - expect(getSelectorInput().value).toBe('slothClusterNorth'); - }); - - it('Can select a node from an upper level', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultClustersSelect()); - await userEvents.click(getSelectorApply()); - expect(getSelectorInput().value).toBe('Cluster Index Helper'); - }); - - it('Respects only one select per container', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultClustersExpand()); - await userEvents.click(getResultClustersSlothClusterNorthRadio()); - expect(getResultClustersSlothClusterNorthRadio().checked).toBe(true); - expect(getResultClustersSlothClusterSouthRadio().checked).toBe(false); - await userEvents.click(getResultClustersSlothClusterSouthRadio()); - expect(getResultClustersSlothClusterNorthRadio().checked).toBe(false); - expect(getResultClustersSlothClusterSouthRadio().checked).toBe(true); - }); - - it('Search works', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.type(getTreeSearch(), 'Clusters'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); - expect(queryResultApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument(); - expect(queryResultApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument(); - expect(getResultApplicationsClustersSelect()).toBeInTheDocument(); - await userEvents.clear(getTreeSearch()); - await userEvents.type(getTreeSearch(), 'sloth'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4)); - expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); - expect(getResultApplicationsSlothVoteTrackerSelect()).toBeInTheDocument(); - expect(queryResultApplicationsClustersTitle()).not.toBeInTheDocument(); - }); - - it('Opens to a selected scope', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultClustersExpand()); - await userEvents.click(getSelectorApply()); - await userEvents.click(getSelectorInput()); - expect(queryResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); - }); - - it('Persists a scope', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.type(getTreeSearch(), 'slothVoteTracker'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); - expect(getPersistedApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); - expect(queryPersistedApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument(); - expect(queryResultApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument(); - expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument(); - }); - - it('Does not persist a retrieved scope', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.type(getTreeSearch(), 'slothPictureFactory'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); - expect(queryPersistedApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument(); - expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); - }); - - it('Removes persisted nodes', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.type(getTreeSearch(), 'slothVoteTracker'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); - await userEvents.clear(getTreeSearch()); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4)); - expect(queryPersistedApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument(); - expect(queryPersistedApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument(); - expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); - expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument(); - }); - - it('Persists nodes from search', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.type(getTreeSearch(), 'sloth'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); - await userEvents.type(getTreeSearch(), 'slothunknown'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4)); - expect(getPersistedApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); - expect(getPersistedApplicationsSlothVoteTrackerTitle()).toBeInTheDocument(); - await userEvents.clear(getTreeSearch()); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(5)); - expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); - expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument(); - }); - - it('Selects a persisted scope', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.type(getTreeSearch(), 'slothVoteTracker'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); - await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getSelectorApply()); - expect(getSelectorInput().value).toBe('slothPictureFactory, slothVoteTracker'); - }); - - it('Deselects a persisted scope', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.type(getTreeSearch(), 'slothVoteTracker'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); - await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getSelectorApply()); - expect(getSelectorInput().value).toBe('slothPictureFactory, slothVoteTracker'); - await userEvents.click(getSelectorInput()); - await userEvents.click(getPersistedApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - expect(getSelectorInput().value).toBe('slothVoteTracker'); - }); - - it('Shows the proper headline', async () => { - await userEvents.click(getSelectorInput()); - expect(getTreeHeadline()).toHaveTextContent('Recommended'); - await userEvents.type(getTreeSearch(), 'Applications'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(2)); - expect(getTreeHeadline()).toHaveTextContent('Results'); - await userEvents.type(getTreeSearch(), 'unknown'); - await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); - expect(getTreeHeadline()).toHaveTextContent('No results found for your query'); - }); - }); - - describe('Selector', () => { - it('Opens', async () => { - await userEvents.click(getSelectorInput()); - expect(getSelectorApply()).toBeInTheDocument(); - }); - - it('Fetches scope details on save', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultClustersSelect()); - await userEvents.click(getSelectorApply()); - await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled()); - expect(getClosestScopesFacade(dashboardScene)?.value).toEqual( - mocksScopes.filter(({ metadata: { name } }) => name === 'indexHelperCluster') - ); - }); - - it("Doesn't save the scopes on close", async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultClustersSelect()); - await userEvents.click(getSelectorCancel()); - await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled()); - expect(getClosestScopesFacade(dashboardScene)?.value).toEqual([]); - }); - - it('Shows selected scopes', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultClustersSelect()); - await userEvents.click(getSelectorApply()); - expect(getSelectorInput().value).toEqual('Cluster Index Helper'); - }); - }); - - describe('Dashboards list', () => { - it('Toggles expanded state', async () => { - await userEvents.click(getDashboardsExpand()); - expect(getNotFoundNoScopes()).toBeInTheDocument(); - }); - - it('Does not fetch dashboards list when the list is not expanded', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - await waitFor(() => expect(fetchSuggestedDashboardsSpy).not.toHaveBeenCalled()); - }); - - it('Fetches dashboards list when the list is expanded', async () => { - await userEvents.click(getDashboardsExpand()); - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled()); - }); - - it('Fetches dashboards list when the list is expanded after scope selection', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - await userEvents.click(getDashboardsExpand()); - await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled()); - }); - - it('Shows dashboards for multiple scopes', async () => { - await userEvents.click(getDashboardsExpand()); - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - expect(getDashboard('1')).toBeInTheDocument(); - expect(getDashboard('2')).toBeInTheDocument(); - expect(queryDashboard('3')).not.toBeInTheDocument(); - expect(queryDashboard('4')).not.toBeInTheDocument(); - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getSelectorApply()); - expect(getDashboard('1')).toBeInTheDocument(); - expect(getDashboard('2')).toBeInTheDocument(); - expect(getDashboard('3')).toBeInTheDocument(); - expect(getDashboard('4')).toBeInTheDocument(); - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - expect(queryDashboard('1')).not.toBeInTheDocument(); - expect(queryDashboard('2')).not.toBeInTheDocument(); - expect(getDashboard('3')).toBeInTheDocument(); - expect(getDashboard('4')).toBeInTheDocument(); - }); - - it('Filters the dashboards list', async () => { - await userEvents.click(getDashboardsExpand()); - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - expect(getDashboard('1')).toBeInTheDocument(); - expect(getDashboard('2')).toBeInTheDocument(); - await userEvents.type(getDashboardsSearch(), '1'); - expect(queryDashboard('2')).not.toBeInTheDocument(); - }); - - it('Deduplicates the dashboards list', async () => { - await userEvents.click(getDashboardsExpand()); - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsClustersExpand()); - await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect()); - await userEvents.click(getResultApplicationsClustersSlothClusterSouthSelect()); - await userEvents.click(getSelectorApply()); - expect(queryAllDashboard('5')).toHaveLength(1); - expect(queryAllDashboard('6')).toHaveLength(1); - expect(queryAllDashboard('7')).toHaveLength(1); - expect(queryAllDashboard('8')).toHaveLength(1); - }); - - it('Does show a proper message when no scopes are selected', async () => { - await userEvents.click(getDashboardsExpand()); - expect(getNotFoundNoScopes()).toBeInTheDocument(); - expect(queryDashboardsSearch()).not.toBeInTheDocument(); - }); - - it('Does not show the input when there are no dashboards found for scope', async () => { - await userEvents.click(getDashboardsExpand()); - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultClustersExpand()); - await userEvents.click(getResultClustersSlothClusterEastRadio()); - await userEvents.click(getSelectorApply()); - expect(getNotFoundForScope()).toBeInTheDocument(); - expect(queryDashboardsSearch()).not.toBeInTheDocument(); - }); - - it('Does show the input and a message when there are no dashboards found for filter', async () => { - await userEvents.click(getDashboardsExpand()); - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - await userEvents.type(getDashboardsSearch(), 'unknown'); - expect(queryDashboardsSearch()).toBeInTheDocument(); - expect(getNotFoundForFilter()).toBeInTheDocument(); - await userEvents.click(getNotFoundForFilterClear()); - expect(getDashboardsSearch().value).toBe(''); - }); - }); - - describe('View mode', () => { - it('Enters view mode', async () => { - await act(async () => dashboardScene.onEnterEditMode()); - expect(scopesSelectorScene?.state?.isReadOnly).toEqual(true); - expect(scopesDashboardsScene?.state?.isPanelOpened).toEqual(false); - }); - - it('Closes selector on enter', async () => { - await userEvents.click(getSelectorInput()); - await act(async () => dashboardScene.onEnterEditMode()); - expect(querySelectorApply()).not.toBeInTheDocument(); - }); - - it('Closes dashboards list on enter', async () => { - await userEvents.click(getDashboardsExpand()); - await act(async () => dashboardScene.onEnterEditMode()); - expect(queryDashboardsContainer()).not.toBeInTheDocument(); - }); - - it('Does not open selector when view mode is active', async () => { - await act(async () => dashboardScene.onEnterEditMode()); - await userEvents.click(getSelectorInput()); - expect(querySelectorApply()).not.toBeInTheDocument(); - }); - - it('Disables the expand button when view mode is active', async () => { - await act(async () => dashboardScene.onEnterEditMode()); - expect(getDashboardsExpand()).toBeDisabled(); - }); - }); - - describe('Enrichers', () => { - it('Data requests', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - await waitFor(() => { - const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; - expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( - mocksScopes.filter(({ metadata: { name } }) => name === 'slothPictureFactory') - ); - }); - - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getSelectorApply()); - await waitFor(() => { - const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; - expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( - mocksScopes.filter( - ({ metadata: { name } }) => name === 'slothPictureFactory' || name === 'slothVoteTracker' - ) - ); - }); - - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - await waitFor(() => { - const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; - expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( - mocksScopes.filter(({ metadata: { name } }) => name === 'slothVoteTracker') - ); - }); - }); - - it('Filters requests', async () => { - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsExpand()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - await waitFor(() => { - expect(dashboardScene.enrichFiltersRequest().scopes).toEqual( - mocksScopes.filter(({ metadata: { name } }) => name === 'slothPictureFactory') - ); - }); - - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getSelectorApply()); - await waitFor(() => { - expect(dashboardScene.enrichFiltersRequest().scopes).toEqual( - mocksScopes.filter( - ({ metadata: { name } }) => name === 'slothPictureFactory' || name === 'slothVoteTracker' - ) - ); - }); - - await userEvents.click(getSelectorInput()); - await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); - await userEvents.click(getSelectorApply()); - await waitFor(() => { - expect(dashboardScene.enrichFiltersRequest().scopes).toEqual( - mocksScopes.filter(({ metadata: { name } }) => name === 'slothVoteTracker') - ); - }); - }); - }); - }); - - describe('Dashboards API', () => { - describe('Feature flag off', () => { - beforeAll(() => { - config.featureToggles.scopeFilters = true; - config.featureToggles.passScopeToDashboardApi = false; - }); - - beforeEach(() => { - setDashboardAPI(undefined); - locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3'); - }); - - afterEach(() => { - resetScenes(); - cleanup(); - }); - - it('Legacy API should not pass the scopes', async () => { - config.featureToggles.kubernetesDashboards = false; - getDashboardAPI().getDashboardDTO('1'); - await waitFor(() => expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', undefined)); - }); - - it('K8s API should not pass the scopes', async () => { - config.featureToggles.kubernetesDashboards = true; - getDashboardAPI().getDashboardDTO('1'); - await waitFor(() => - expect(getMock).toHaveBeenCalledWith( - '/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto' - ) - ); - }); - }); - - describe('Feature flag on', () => { - beforeAll(() => { - config.featureToggles.scopeFilters = true; - config.featureToggles.passScopeToDashboardApi = true; - }); - - beforeEach(() => { - setDashboardAPI(undefined); - locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3'); - initializeScopes(); - }); - - afterEach(() => { - resetScenes(); - cleanup(); - }); - - it('Legacy API should pass the scopes', async () => { - config.featureToggles.kubernetesDashboards = false; - getDashboardAPI().getDashboardDTO('1'); - await waitFor(() => - expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', { scopes: ['scope1', 'scope2', 'scope3'] }) - ); - }); - - it('K8s API should not pass the scopes', async () => { - config.featureToggles.kubernetesDashboards = true; - getDashboardAPI().getDashboardDTO('1'); - await waitFor(() => - expect(getMock).toHaveBeenCalledWith( - '/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto' - ) - ); - }); - }); - }); -}); diff --git a/public/app/features/scopes/testUtils.tsx b/public/app/features/scopes/testUtils.tsx deleted file mode 100644 index 291007d62a3..00000000000 --- a/public/app/features/scopes/testUtils.tsx +++ /dev/null @@ -1,480 +0,0 @@ -import { screen } from '@testing-library/react'; -import { KBarProvider } from 'kbar'; -import { render } from 'test/test-utils'; - -import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data'; -import { - AdHocFiltersVariable, - behaviors, - GroupByVariable, - sceneGraph, - SceneGridItem, - SceneGridLayout, - SceneQueryRunner, - SceneTimeRange, - SceneVariableSet, - VizPanel, -} from '@grafana/scenes'; -import { AppChrome } from 'app/core/components/AppChrome/AppChrome'; -import { DashboardControls } from 'app/features/dashboard-scene/scene//DashboardControls'; -import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; - -import { ScopesFacade } from './ScopesFacadeScene'; -import { scopesDashboardsScene, scopesSelectorScene } from './instance'; -import { getInitialDashboardsState } from './internal/ScopesDashboardsScene'; -import { initialSelectorState } from './internal/ScopesSelectorScene'; -import * as api from './internal/api'; -import { DASHBOARDS_OPENED_KEY } from './internal/const'; - -export const mocksScopes: Scope[] = [ - { - metadata: { name: 'indexHelperCluster' }, - spec: { - title: 'Cluster Index Helper', - type: 'indexHelper', - description: 'redundant label filter but makes queries faster', - category: 'indexHelpers', - filters: [{ key: 'indexHelper', value: 'cluster', operator: 'equals' }], - }, - }, - { - metadata: { name: 'slothClusterNorth' }, - spec: { - title: 'slothClusterNorth', - type: 'cluster', - description: 'slothClusterNorth', - category: 'clusters', - filters: [{ key: 'cluster', value: 'slothClusterNorth', operator: 'equals' }], - }, - }, - { - metadata: { name: 'slothClusterSouth' }, - spec: { - title: 'slothClusterSouth', - type: 'cluster', - description: 'slothClusterSouth', - category: 'clusters', - filters: [{ key: 'cluster', value: 'slothClusterSouth', operator: 'equals' }], - }, - }, - { - metadata: { name: 'slothClusterEast' }, - spec: { - title: 'slothClusterEast', - type: 'cluster', - description: 'slothClusterEast', - category: 'clusters', - filters: [{ key: 'cluster', value: 'slothClusterEast', operator: 'equals' }], - }, - }, - { - metadata: { name: 'slothPictureFactory' }, - spec: { - title: 'slothPictureFactory', - type: 'app', - description: 'slothPictureFactory', - category: 'apps', - filters: [{ key: 'app', value: 'slothPictureFactory', operator: 'equals' }], - }, - }, - { - metadata: { name: 'slothVoteTracker' }, - spec: { - title: 'slothVoteTracker', - type: 'app', - description: 'slothVoteTracker', - category: 'apps', - filters: [{ key: 'app', value: 'slothVoteTracker', operator: 'equals' }], - }, - }, -] as const; - -export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [ - { - metadata: { name: 'binding1' }, - spec: { dashboard: '1', dashboardTitle: 'My Dashboard 1', scope: 'slothPictureFactory' }, - }, - { - metadata: { name: 'binding2' }, - spec: { dashboard: '2', dashboardTitle: 'My Dashboard 2', scope: 'slothPictureFactory' }, - }, - { - metadata: { name: 'binding3' }, - spec: { dashboard: '3', dashboardTitle: 'My Dashboard 3', scope: 'slothVoteTracker' }, - }, - { - metadata: { name: 'binding4' }, - spec: { dashboard: '4', dashboardTitle: 'My Dashboard 4', scope: 'slothVoteTracker' }, - }, - { - metadata: { name: 'binding5' }, - spec: { dashboard: '5', dashboardTitle: 'My Dashboard 5', scope: 'slothClusterNorth' }, - }, - { - metadata: { name: 'binding6' }, - spec: { dashboard: '6', dashboardTitle: 'My Dashboard 6', scope: 'slothClusterNorth' }, - }, - { - metadata: { name: 'binding7' }, - spec: { dashboard: '7', dashboardTitle: 'My Dashboard 7', scope: 'slothClusterNorth' }, - }, - { - metadata: { name: 'binding8' }, - spec: { dashboard: '5', dashboardTitle: 'My Dashboard 5', scope: 'slothClusterSouth' }, - }, - { - metadata: { name: 'binding9' }, - spec: { dashboard: '6', dashboardTitle: 'My Dashboard 6', scope: 'slothClusterSouth' }, - }, - { - metadata: { name: 'binding10' }, - spec: { dashboard: '8', dashboardTitle: 'My Dashboard 8', scope: 'slothClusterSouth' }, - }, -] as const; - -export const mocksNodes: Array = [ - { - parent: '', - metadata: { name: 'applications' }, - spec: { - nodeType: 'container', - title: 'Applications', - description: 'Application Scopes', - }, - }, - { - parent: '', - metadata: { name: 'clusters' }, - spec: { - nodeType: 'container', - title: 'Clusters', - description: 'Cluster Scopes', - disableMultiSelect: true, - linkType: 'scope', - linkId: 'indexHelperCluster', - }, - }, - { - parent: 'applications', - metadata: { name: 'applications-slothPictureFactory' }, - spec: { - nodeType: 'leaf', - title: 'slothPictureFactory', - description: 'slothPictureFactory', - linkType: 'scope', - linkId: 'slothPictureFactory', - }, - }, - { - parent: 'applications', - metadata: { name: 'applications-slothVoteTracker' }, - spec: { - nodeType: 'leaf', - title: 'slothVoteTracker', - description: 'slothVoteTracker', - linkType: 'scope', - linkId: 'slothVoteTracker', - }, - }, - { - parent: 'applications', - metadata: { name: 'applications-clusters' }, - spec: { - nodeType: 'container', - title: 'Clusters', - description: 'Application/Clusters Scopes', - linkType: 'scope', - linkId: 'indexHelperCluster', - }, - }, - { - parent: 'applications-clusters', - metadata: { name: 'applications-clusters-slothClusterNorth' }, - spec: { - nodeType: 'leaf', - title: 'slothClusterNorth', - description: 'slothClusterNorth', - linkType: 'scope', - linkId: 'slothClusterNorth', - }, - }, - { - parent: 'applications-clusters', - metadata: { name: 'applications-clusters-slothClusterSouth' }, - spec: { - nodeType: 'leaf', - title: 'slothClusterSouth', - description: 'slothClusterSouth', - linkType: 'scope', - linkId: 'slothClusterSouth', - }, - }, - { - parent: 'clusters', - metadata: { name: 'clusters-slothClusterNorth' }, - spec: { - nodeType: 'leaf', - title: 'slothClusterNorth', - description: 'slothClusterNorth', - linkType: 'scope', - linkId: 'slothClusterNorth', - }, - }, - { - parent: 'clusters', - metadata: { name: 'clusters-slothClusterSouth' }, - spec: { - nodeType: 'leaf', - title: 'slothClusterSouth', - description: 'slothClusterSouth', - linkType: 'scope', - linkId: 'slothClusterSouth', - }, - }, - { - parent: 'clusters', - metadata: { name: 'clusters-slothClusterEast' }, - spec: { - nodeType: 'leaf', - title: 'slothClusterEast', - description: 'slothClusterEast', - linkType: 'scope', - linkId: 'slothClusterEast', - }, - }, - { - parent: 'clusters', - metadata: { name: 'clusters-applications' }, - spec: { - nodeType: 'container', - title: 'Applications', - description: 'Clusters/Application Scopes', - }, - }, - { - parent: 'clusters-applications', - metadata: { name: 'clusters-applications-slothPictureFactory' }, - spec: { - nodeType: 'leaf', - title: 'slothPictureFactory', - description: 'slothPictureFactory', - linkType: 'scope', - linkId: 'slothPictureFactory', - }, - }, - { - parent: 'clusters-applications', - metadata: { name: 'clusters-applications-slothVoteTracker' }, - spec: { - nodeType: 'leaf', - title: 'slothVoteTracker', - description: 'slothVoteTracker', - linkType: 'scope', - linkId: 'slothVoteTracker', - }, - }, -] as const; - -export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes'); -export const fetchScopeSpy = jest.spyOn(api, 'fetchScope'); -export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes'); -export const fetchSuggestedDashboardsSpy = jest.spyOn(api, 'fetchSuggestedDashboards'); - -export const getMock = jest - .fn() - .mockImplementation((url: string, params: { parent: string; scope: string[]; query?: string }) => { - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) { - return { - items: mocksNodes.filter( - ({ parent, spec: { title } }) => parent === params.parent && title.includes(params.query ?? '') - ), - }; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) { - const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', ''); - - return mocksScopes.find((scope) => scope.metadata.name === name) ?? {}; - } - - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) { - return { - items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) => - params.scope.includes(bindingScope) - ), - }; - } - - if (url.startsWith('/api/dashboards/uid/')) { - return {}; - } - - if (url.startsWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/')) { - return { - metadata: { - name: '1', - }, - }; - } - - return {}; - }); - -const selectors = { - tree: { - search: 'scopes-tree-search', - headline: 'scopes-tree-headline', - select: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-checkbox`, - radio: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-radio`, - expand: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-expand`, - title: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-title`, - }, - selector: { - input: 'scopes-selector-input', - container: 'scopes-selector-container', - loading: 'scopes-selector-loading', - apply: 'scopes-selector-apply', - cancel: 'scopes-selector-cancel', - }, - dashboards: { - expand: 'scopes-dashboards-expand', - container: 'scopes-dashboards-container', - search: 'scopes-dashboards-search', - loading: 'scopes-dashboards-loading', - dashboard: (uid: string) => `scopes-dashboards-${uid}`, - notFoundNoScopes: 'scopes-dashboards-notFoundNoScopes', - notFoundForScope: 'scopes-dashboards-notFoundForScope', - notFoundForFilter: 'scopes-dashboards-notFoundForFilter', - notFoundForFilterClear: 'scopes-dashboards-notFoundForFilter-clear', - }, -}; - -export const getSelectorInput = () => screen.getByTestId(selectors.selector.input); -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 getDashboardsExpand = () => screen.getByTestId(selectors.dashboards.expand); -export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container); -export const queryDashboardsSearch = () => screen.queryByTestId(selectors.dashboards.search); -export const getDashboardsSearch = () => screen.getByTestId(selectors.dashboards.search); -export const queryAllDashboard = (uid: string) => screen.queryAllByTestId(selectors.dashboards.dashboard(uid)); -export const queryDashboard = (uid: string) => screen.queryByTestId(selectors.dashboards.dashboard(uid)); -export const getDashboard = (uid: string) => screen.getByTestId(selectors.dashboards.dashboard(uid)); -export const getNotFoundNoScopes = () => screen.getByTestId(selectors.dashboards.notFoundNoScopes); -export const getNotFoundForScope = () => screen.getByTestId(selectors.dashboards.notFoundForScope); -export const getNotFoundForFilter = () => screen.getByTestId(selectors.dashboards.notFoundForFilter); -export const getNotFoundForFilterClear = () => screen.getByTestId(selectors.dashboards.notFoundForFilterClear); - -export const getTreeSearch = () => screen.getByTestId(selectors.tree.search); -export const getTreeHeadline = () => screen.getByTestId(selectors.tree.headline); -export const getResultApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications', 'result')); -export const queryResultApplicationsSlothPictureFactoryTitle = () => - screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory', 'result')); -export const getResultApplicationsSlothPictureFactoryTitle = () => - screen.getByTestId(selectors.tree.title('applications-slothPictureFactory', 'result')); -export const getResultApplicationsSlothPictureFactorySelect = () => - screen.getByTestId(selectors.tree.select('applications-slothPictureFactory', 'result')); -export const queryPersistedApplicationsSlothPictureFactoryTitle = () => - screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory', 'persisted')); -export const getPersistedApplicationsSlothPictureFactoryTitle = () => - screen.getByTestId(selectors.tree.title('applications-slothPictureFactory', 'persisted')); -export const getPersistedApplicationsSlothPictureFactorySelect = () => - screen.getByTestId(selectors.tree.select('applications-slothPictureFactory', 'persisted')); -export const queryResultApplicationsSlothVoteTrackerTitle = () => - screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker', 'result')); -export const getResultApplicationsSlothVoteTrackerTitle = () => - screen.getByTestId(selectors.tree.title('applications-slothVoteTracker', 'result')); -export const getResultApplicationsSlothVoteTrackerSelect = () => - screen.getByTestId(selectors.tree.select('applications-slothVoteTracker', 'result')); -export const queryPersistedApplicationsSlothVoteTrackerTitle = () => - screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker', 'persisted')); -export const getPersistedApplicationsSlothVoteTrackerTitle = () => - screen.getByTestId(selectors.tree.title('applications-slothVoteTracker', 'persisted')); -export const queryResultApplicationsClustersTitle = () => - screen.queryByTestId(selectors.tree.title('applications-clusters', 'result')); -export const getResultApplicationsClustersSelect = () => - screen.getByTestId(selectors.tree.select('applications-clusters', 'result')); -export const getResultApplicationsClustersExpand = () => - screen.getByTestId(selectors.tree.expand('applications-clusters', 'result')); -export const getResultApplicationsClustersSlothClusterNorthSelect = () => - screen.getByTestId(selectors.tree.select('applications-clusters-slothClusterNorth', 'result')); -export const getResultApplicationsClustersSlothClusterSouthSelect = () => - screen.getByTestId(selectors.tree.select('applications-clusters-slothClusterSouth', 'result')); - -export const getResultClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters', 'result')); -export const getResultClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters', 'result')); -export const getResultClustersSlothClusterNorthRadio = () => - screen.getByTestId(selectors.tree.radio('clusters-slothClusterNorth', 'result')); -export const getResultClustersSlothClusterSouthRadio = () => - screen.getByTestId(selectors.tree.radio('clusters-slothClusterSouth', 'result')); -export const getResultClustersSlothClusterEastRadio = () => - screen.getByTestId(selectors.tree.radio('clusters-slothClusterEast', 'result')); - -export function buildTestScene(overrides: Partial = {}) { - return new DashboardScene({ - title: 'hello', - uid: 'dash-1', - description: 'hello description', - tags: ['tag1', 'tag2'], - editable: true, - $timeRange: new SceneTimeRange({ - timeZone: 'browser', - }), - controls: new DashboardControls({}), - $behaviors: [ - new behaviors.CursorSync({}), - new ScopesFacade({ - handler: (facade) => sceneGraph.getTimeRange(facade).onRefresh(), - }), - ], - $variables: new SceneVariableSet({ - variables: [ - new AdHocFiltersVariable({ - name: 'adhoc', - datasource: { uid: 'my-ds-uid' }, - }), - new GroupByVariable({ - name: 'groupby', - datasource: { uid: 'my-ds-uid' }, - }), - ], - }), - body: new SceneGridLayout({ - children: [ - new SceneGridItem({ - key: 'griditem-1', - x: 0, - y: 0, - width: 300, - height: 300, - body: new VizPanel({ - title: 'Panel A', - key: 'panel-1', - pluginId: 'table', - $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), - }), - }), - ], - }), - ...overrides, - }); -} - -export function renderDashboard(dashboardScene: DashboardScene) { - return render( - - - - - - ); -} - -export function resetScenes() { - scopesSelectorScene?.setState(initialSelectorState); - - localStorage.removeItem(DASHBOARDS_OPENED_KEY); - - scopesDashboardsScene?.setState(getInitialDashboardsState()); -} diff --git a/public/app/features/scopes/tests/dashboardsApi.test.ts b/public/app/features/scopes/tests/dashboardsApi.test.ts new file mode 100644 index 00000000000..530f7b49717 --- /dev/null +++ b/public/app/features/scopes/tests/dashboardsApi.test.ts @@ -0,0 +1,84 @@ +import { cleanup } from '@testing-library/react'; + +import { config, locationService } from '@grafana/runtime'; +import { getDashboardAPI, setDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; + +import { initializeScopes } from '../instance'; + +import { getMock } from './utils/mocks'; +import { resetScenes } from './utils/render'; + +jest.mock('@grafana/runtime', () => ({ + __esModule: true, + ...jest.requireActual('@grafana/runtime'), + getBackendSrv: () => ({ + get: getMock, + }), +})); + +describe('Scopes', () => { + describe('Dashboards API', () => { + describe('Feature flag off', () => { + beforeAll(() => { + config.featureToggles.scopeFilters = true; + config.featureToggles.passScopeToDashboardApi = false; + }); + + beforeEach(() => { + setDashboardAPI(undefined); + locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3'); + }); + + afterEach(() => { + resetScenes(); + cleanup(); + }); + + it('Legacy API should not pass the scopes', async () => { + config.featureToggles.kubernetesDashboards = false; + await getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', undefined); + }); + + it('K8s API should not pass the scopes', async () => { + config.featureToggles.kubernetesDashboards = true; + await getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith( + '/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto' + ); + }); + }); + + describe('Feature flag on', () => { + beforeAll(() => { + config.featureToggles.scopeFilters = true; + config.featureToggles.passScopeToDashboardApi = true; + }); + + beforeEach(() => { + setDashboardAPI(undefined); + locationService.push('/?scopes=scope1&scopes=scope2&scopes=scope3'); + initializeScopes(); + }); + + afterEach(() => { + resetScenes(); + cleanup(); + }); + + it('Legacy API should pass the scopes', async () => { + config.featureToggles.kubernetesDashboards = false; + await getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith('/api/dashboards/uid/1', { scopes: ['scope1', 'scope2', 'scope3'] }); + }); + + it('K8s API should not pass the scopes', async () => { + config.featureToggles.kubernetesDashboards = true; + await getDashboardAPI().getDashboardDTO('1'); + expect(getMock).toHaveBeenCalledWith( + '/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/1/dto' + ); + }); + }); + }); +}); diff --git a/public/app/features/scopes/tests/scopes.test.ts b/public/app/features/scopes/tests/scopes.test.ts new file mode 100644 index 00000000000..5d886a73b5b --- /dev/null +++ b/public/app/features/scopes/tests/scopes.test.ts @@ -0,0 +1,687 @@ +import { act, cleanup, waitFor } from '@testing-library/react'; +import userEvents from '@testing-library/user-event'; + +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { config, setPluginImportUtils } from '@grafana/runtime'; +import { sceneGraph } from '@grafana/scenes'; +import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; + +import { initializeScopes, scopesDashboardsScene, scopesSelectorScene } from '../instance'; +import { getClosestScopesFacade } from '../utils'; + +import { + fetchDashboardsSpy, + fetchNodesSpy, + fetchScopeSpy, + fetchSelectedScopesSpy, + getMock, + mocksScopes, +} from './utils/mocks'; +import { buildTestScene, renderDashboard, resetScenes } from './utils/render'; +import { + getDashboard, + getDashboardFolderExpand, + getDashboardsExpand, + getDashboardsSearch, + getNotFoundForFilter, + getNotFoundForFilterClear, + getNotFoundForScope, + getNotFoundNoScopes, + getPersistedApplicationsMimirSelect, + getPersistedApplicationsMimirTitle, + getResultApplicationsCloudDevSelect, + getResultApplicationsCloudExpand, + getResultApplicationsCloudOpsSelect, + getResultApplicationsCloudSelect, + getResultApplicationsExpand, + getResultApplicationsGrafanaSelect, + getResultApplicationsGrafanaTitle, + getResultApplicationsMimirSelect, + getResultApplicationsMimirTitle, + getResultCloudDevRadio, + getResultCloudExpand, + getResultCloudOpsRadio, + getResultCloudSelect, + getSelectorApply, + getSelectorCancel, + getSelectorInput, + getTreeHeadline, + getTreeSearch, + queryAllDashboard, + queryDashboard, + queryDashboardFolderExpand, + queryDashboardsContainer, + queryDashboardsSearch, + queryPersistedApplicationsGrafanaTitle, + queryPersistedApplicationsMimirTitle, + queryResultApplicationsCloudTitle, + queryResultApplicationsGrafanaTitle, + queryResultApplicationsMimirTitle, + querySelectorApply, +} from './utils/selectors'; + +jest.mock('@grafana/runtime', () => ({ + __esModule: true, + ...jest.requireActual('@grafana/runtime'), + useChromeHeaderHeight: jest.fn(), + getBackendSrv: () => ({ + get: getMock, + }), + usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), +})); + +const panelPlugin = getPanelPlugin({ + id: 'table', + skipDataQuery: true, +}); + +config.panels['table'] = panelPlugin.meta; + +setPluginImportUtils({ + importPanelPlugin: () => Promise.resolve(panelPlugin), + getPanelPluginFromCache: () => undefined, +}); + +describe('Scopes', () => { + describe('Feature flag off', () => { + beforeAll(() => { + config.featureToggles.scopeFilters = false; + config.featureToggles.groupByVariable = true; + + initializeScopes(); + }); + + it('Does not initialize', () => { + const dashboardScene = buildTestScene(); + dashboardScene.activate(); + expect(scopesSelectorScene).toBeNull(); + }); + }); + + describe('Feature flag on', () => { + let dashboardScene: DashboardScene; + + beforeAll(() => { + config.featureToggles.scopeFilters = true; + config.featureToggles.groupByVariable = true; + }); + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(jest.fn()); + + fetchNodesSpy.mockClear(); + fetchScopeSpy.mockClear(); + fetchSelectedScopesSpy.mockClear(); + fetchDashboardsSpy.mockClear(); + getMock.mockClear(); + + initializeScopes(); + + dashboardScene = buildTestScene(); + + renderDashboard(dashboardScene); + }); + + afterEach(() => { + resetScenes(); + cleanup(); + }); + + describe('Tree', () => { + it('Navigates through scopes nodes', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsCloudExpand()); + await userEvents.click(getResultApplicationsExpand()); + }); + + it('Fetches scope details on select', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await waitFor(() => expect(fetchScopeSpy).toHaveBeenCalledTimes(1)); + }); + + it('Selects the proper scopes', async () => { + await act(async () => + scopesSelectorScene?.updateScopes([ + { scopeName: 'grafana', path: [] }, + { scopeName: 'mimir', path: [] }, + ]) + ); + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + expect(getResultApplicationsGrafanaSelect()).toBeChecked(); + expect(getResultApplicationsMimirSelect()).toBeChecked(); + }); + + it('Can select scopes from same level', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.click(getResultApplicationsCloudSelect()); + await userEvents.click(getSelectorApply()); + expect(getSelectorInput().value).toBe('Grafana, Mimir, Cloud'); + }); + + it('Can select a node from an inner level', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getResultApplicationsCloudExpand()); + await userEvents.click(getResultApplicationsCloudDevSelect()); + await userEvents.click(getSelectorApply()); + expect(getSelectorInput().value).toBe('Dev'); + }); + + it('Can select a node from an upper level', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultCloudSelect()); + await userEvents.click(getSelectorApply()); + expect(getSelectorInput().value).toBe('Cloud'); + }); + + it('Respects only one select per container', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultCloudExpand()); + await userEvents.click(getResultCloudDevRadio()); + expect(getResultCloudDevRadio().checked).toBe(true); + expect(getResultCloudOpsRadio().checked).toBe(false); + await userEvents.click(getResultCloudOpsRadio()); + expect(getResultCloudDevRadio().checked).toBe(false); + expect(getResultCloudOpsRadio().checked).toBe(true); + }); + + it('Search works', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.type(getTreeSearch(), 'Cloud'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + expect(queryResultApplicationsGrafanaTitle()).not.toBeInTheDocument(); + expect(queryResultApplicationsMimirTitle()).not.toBeInTheDocument(); + expect(getResultApplicationsCloudSelect()).toBeInTheDocument(); + await userEvents.clear(getTreeSearch()); + await userEvents.type(getTreeSearch(), 'Grafana'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4)); + expect(getResultApplicationsGrafanaSelect()).toBeInTheDocument(); + expect(queryResultApplicationsCloudTitle()).not.toBeInTheDocument(); + }); + + it('Opens to a selected scope', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultCloudExpand()); + await userEvents.click(getSelectorApply()); + await userEvents.click(getSelectorInput()); + expect(queryResultApplicationsMimirTitle()).toBeInTheDocument(); + }); + + it('Persists a scope', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.type(getTreeSearch(), 'grafana'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + expect(getPersistedApplicationsMimirTitle()).toBeInTheDocument(); + expect(queryPersistedApplicationsGrafanaTitle()).not.toBeInTheDocument(); + expect(queryResultApplicationsMimirTitle()).not.toBeInTheDocument(); + expect(getResultApplicationsGrafanaTitle()).toBeInTheDocument(); + }); + + it('Does not persist a retrieved scope', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.type(getTreeSearch(), 'mimir'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + expect(queryPersistedApplicationsMimirTitle()).not.toBeInTheDocument(); + expect(getResultApplicationsMimirTitle()).toBeInTheDocument(); + }); + + it('Removes persisted nodes', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.type(getTreeSearch(), 'grafana'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + await userEvents.clear(getTreeSearch()); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4)); + expect(queryPersistedApplicationsMimirTitle()).not.toBeInTheDocument(); + expect(queryPersistedApplicationsGrafanaTitle()).not.toBeInTheDocument(); + expect(getResultApplicationsMimirTitle()).toBeInTheDocument(); + expect(getResultApplicationsGrafanaTitle()).toBeInTheDocument(); + }); + + it('Persists nodes from search', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.type(getTreeSearch(), 'mimir'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.type(getTreeSearch(), 'unknown'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4)); + expect(getPersistedApplicationsMimirTitle()).toBeInTheDocument(); + await userEvents.clear(getTreeSearch()); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(5)); + expect(getResultApplicationsMimirTitle()).toBeInTheDocument(); + expect(getResultApplicationsGrafanaTitle()).toBeInTheDocument(); + }); + + it('Selects a persisted scope', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.type(getTreeSearch(), 'grafana'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getSelectorApply()); + expect(getSelectorInput().value).toBe('Mimir, Grafana'); + }); + + it('Deselects a persisted scope', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.type(getTreeSearch(), 'grafana'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getSelectorApply()); + expect(getSelectorInput().value).toBe('Mimir, Grafana'); + await userEvents.click(getSelectorInput()); + await userEvents.click(getPersistedApplicationsMimirSelect()); + await userEvents.click(getSelectorApply()); + expect(getSelectorInput().value).toBe('Grafana'); + }); + + it('Shows the proper headline', async () => { + await userEvents.click(getSelectorInput()); + expect(getTreeHeadline()).toHaveTextContent('Recommended'); + await userEvents.type(getTreeSearch(), 'Applications'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(2)); + expect(getTreeHeadline()).toHaveTextContent('Results'); + await userEvents.type(getTreeSearch(), 'unknown'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + expect(getTreeHeadline()).toHaveTextContent('No results found for your query'); + }); + }); + + describe('Selector', () => { + it('Opens', async () => { + await userEvents.click(getSelectorInput()); + expect(getSelectorApply()).toBeInTheDocument(); + }); + + it('Fetches scope details on save', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultCloudSelect()); + await userEvents.click(getSelectorApply()); + await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled()); + expect(getClosestScopesFacade(dashboardScene)?.value).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'cloud') + ); + }); + + it('Does not save the scopes on close', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultCloudSelect()); + await userEvents.click(getSelectorCancel()); + await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled()); + expect(getClosestScopesFacade(dashboardScene)?.value).toEqual([]); + }); + + it('Shows selected scopes', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultCloudSelect()); + await userEvents.click(getSelectorApply()); + expect(getSelectorInput().value).toEqual('Cloud'); + }); + }); + + describe('Dashboards list', () => { + it('Toggles expanded state', async () => { + await userEvents.click(getDashboardsExpand()); + expect(getNotFoundNoScopes()).toBeInTheDocument(); + }); + + it('Does not fetch dashboards list when the list is not expanded', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.click(getSelectorApply()); + await waitFor(() => expect(fetchDashboardsSpy).not.toHaveBeenCalled()); + }); + + it('Fetches dashboards list when the list is expanded', async () => { + await userEvents.click(getDashboardsExpand()); + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.click(getSelectorApply()); + await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled()); + }); + + it('Fetches dashboards list when the list is expanded after scope selection', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.click(getSelectorApply()); + await userEvents.click(getDashboardsExpand()); + await waitFor(() => expect(fetchDashboardsSpy).toHaveBeenCalled()); + }); + + it('Shows dashboards for multiple scopes', async () => { + await userEvents.click(getDashboardsExpand()); + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getSelectorApply()); + await userEvents.click(getDashboardFolderExpand('General')); + await userEvents.click(getDashboardFolderExpand('Observability')); + await userEvents.click(getDashboardFolderExpand('Usage')); + expect(queryDashboardFolderExpand('Components')).not.toBeInTheDocument(); + expect(queryDashboardFolderExpand('Investigations')).not.toBeInTheDocument(); + expect(getDashboard('general-data-sources')).toBeInTheDocument(); + expect(getDashboard('general-usage')).toBeInTheDocument(); + expect(getDashboard('observability-backend-errors')).toBeInTheDocument(); + expect(getDashboard('observability-backend-logs')).toBeInTheDocument(); + expect(getDashboard('observability-frontend-errors')).toBeInTheDocument(); + expect(getDashboard('observability-frontend-logs')).toBeInTheDocument(); + expect(getDashboard('usage-data-sources')).toBeInTheDocument(); + expect(getDashboard('usage-stats')).toBeInTheDocument(); + expect(getDashboard('usage-usage-overview')).toBeInTheDocument(); + expect(getDashboard('frontend')).toBeInTheDocument(); + expect(getDashboard('overview')).toBeInTheDocument(); + expect(getDashboard('stats')).toBeInTheDocument(); + expect(queryDashboard('multiple3-datasource-errors')).not.toBeInTheDocument(); + expect(queryDashboard('multiple4-datasource-logs')).not.toBeInTheDocument(); + expect(queryDashboard('multiple0-ingester')).not.toBeInTheDocument(); + expect(queryDashboard('multiple1-distributor')).not.toBeInTheDocument(); + expect(queryDashboard('multiple2-compacter')).not.toBeInTheDocument(); + expect(queryDashboard('another-stats')).not.toBeInTheDocument(); + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.click(getSelectorApply()); + await userEvents.click(getDashboardFolderExpand('General')); + await userEvents.click(getDashboardFolderExpand('Observability')); + await userEvents.click(getDashboardFolderExpand('Usage')); + await userEvents.click(getDashboardFolderExpand('Components')); + await userEvents.click(getDashboardFolderExpand('Investigations')); + expect(getDashboard('general-data-sources')).toBeInTheDocument(); + expect(getDashboard('general-usage')).toBeInTheDocument(); + expect(getDashboard('observability-backend-errors')).toBeInTheDocument(); + expect(getDashboard('observability-backend-logs')).toBeInTheDocument(); + expect(getDashboard('observability-frontend-errors')).toBeInTheDocument(); + expect(getDashboard('observability-frontend-logs')).toBeInTheDocument(); + expect(getDashboard('usage-data-sources')).toBeInTheDocument(); + expect(getDashboard('usage-stats')).toBeInTheDocument(); + expect(getDashboard('usage-usage-overview')).toBeInTheDocument(); + expect(getDashboard('frontend')).toBeInTheDocument(); + expect(getDashboard('overview')).toBeInTheDocument(); + expect(getDashboard('stats')).toBeInTheDocument(); + expect(queryAllDashboard('multiple3-datasource-errors')).toHaveLength(2); + expect(queryAllDashboard('multiple4-datasource-logs')).toHaveLength(2); + expect(queryAllDashboard('multiple0-ingester')).toHaveLength(2); + expect(queryAllDashboard('multiple1-distributor')).toHaveLength(2); + expect(queryAllDashboard('multiple2-compacter')).toHaveLength(2); + expect(getDashboard('another-stats')).toBeInTheDocument(); + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.click(getSelectorApply()); + await userEvents.click(getDashboardFolderExpand('General')); + await userEvents.click(getDashboardFolderExpand('Observability')); + await userEvents.click(getDashboardFolderExpand('Usage')); + expect(queryDashboardFolderExpand('Components')).not.toBeInTheDocument(); + expect(queryDashboardFolderExpand('Investigations')).not.toBeInTheDocument(); + expect(getDashboard('general-data-sources')).toBeInTheDocument(); + expect(getDashboard('general-usage')).toBeInTheDocument(); + expect(getDashboard('observability-backend-errors')).toBeInTheDocument(); + expect(getDashboard('observability-backend-logs')).toBeInTheDocument(); + expect(getDashboard('observability-frontend-errors')).toBeInTheDocument(); + expect(getDashboard('observability-frontend-logs')).toBeInTheDocument(); + expect(getDashboard('usage-data-sources')).toBeInTheDocument(); + expect(getDashboard('usage-stats')).toBeInTheDocument(); + expect(getDashboard('usage-usage-overview')).toBeInTheDocument(); + expect(getDashboard('frontend')).toBeInTheDocument(); + expect(getDashboard('overview')).toBeInTheDocument(); + expect(getDashboard('stats')).toBeInTheDocument(); + expect(queryDashboard('multiple3-datasource-errors')).not.toBeInTheDocument(); + expect(queryDashboard('multiple4-datasource-logs')).not.toBeInTheDocument(); + expect(queryDashboard('multiple0-ingester')).not.toBeInTheDocument(); + expect(queryDashboard('multiple1-distributor')).not.toBeInTheDocument(); + expect(queryDashboard('multiple2-compacter')).not.toBeInTheDocument(); + expect(queryDashboard('another-stats')).not.toBeInTheDocument(); + }); + + it('Filters the dashboards list for dashboards', async () => { + await userEvents.click(getDashboardsExpand()); + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getSelectorApply()); + await userEvents.click(getDashboardFolderExpand('General')); + await userEvents.click(getDashboardFolderExpand('Observability')); + await userEvents.click(getDashboardFolderExpand('Usage')); + expect(getDashboard('general-data-sources')).toBeInTheDocument(); + expect(getDashboard('general-usage')).toBeInTheDocument(); + expect(getDashboard('observability-backend-errors')).toBeInTheDocument(); + expect(getDashboard('observability-backend-logs')).toBeInTheDocument(); + expect(getDashboard('observability-frontend-errors')).toBeInTheDocument(); + expect(getDashboard('observability-frontend-logs')).toBeInTheDocument(); + expect(getDashboard('usage-data-sources')).toBeInTheDocument(); + expect(getDashboard('usage-stats')).toBeInTheDocument(); + expect(getDashboard('usage-usage-overview')).toBeInTheDocument(); + expect(getDashboard('frontend')).toBeInTheDocument(); + expect(getDashboard('overview')).toBeInTheDocument(); + expect(getDashboard('stats')).toBeInTheDocument(); + await userEvents.type(getDashboardsSearch(), 'Stats'); + await waitFor(() => { + expect(queryDashboard('general-data-sources')).not.toBeInTheDocument(); + expect(queryDashboard('general-usage')).not.toBeInTheDocument(); + expect(queryDashboard('observability-backend-errors')).not.toBeInTheDocument(); + expect(queryDashboard('observability-backend-logs')).not.toBeInTheDocument(); + expect(queryDashboard('observability-frontend-errors')).not.toBeInTheDocument(); + expect(queryDashboard('observability-frontend-logs')).not.toBeInTheDocument(); + expect(queryDashboard('usage-data-sources')).not.toBeInTheDocument(); + expect(getDashboard('usage-stats')).toBeInTheDocument(); + expect(queryDashboard('usage-usage-overview')).not.toBeInTheDocument(); + expect(queryDashboard('frontend')).not.toBeInTheDocument(); + expect(queryDashboard('overview')).not.toBeInTheDocument(); + expect(getDashboard('stats')).toBeInTheDocument(); + }); + }); + + it('Filters the dashboards list for folders', async () => { + await userEvents.click(getDashboardsExpand()); + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getSelectorApply()); + await userEvents.click(getDashboardFolderExpand('General')); + await userEvents.click(getDashboardFolderExpand('Observability')); + await userEvents.click(getDashboardFolderExpand('Usage')); + expect(getDashboard('general-data-sources')).toBeInTheDocument(); + expect(getDashboard('general-usage')).toBeInTheDocument(); + expect(getDashboard('observability-backend-errors')).toBeInTheDocument(); + expect(getDashboard('observability-backend-logs')).toBeInTheDocument(); + expect(getDashboard('observability-frontend-errors')).toBeInTheDocument(); + expect(getDashboard('observability-frontend-logs')).toBeInTheDocument(); + expect(getDashboard('usage-data-sources')).toBeInTheDocument(); + expect(getDashboard('usage-stats')).toBeInTheDocument(); + expect(getDashboard('usage-usage-overview')).toBeInTheDocument(); + expect(getDashboard('frontend')).toBeInTheDocument(); + expect(getDashboard('overview')).toBeInTheDocument(); + expect(getDashboard('stats')).toBeInTheDocument(); + await userEvents.type(getDashboardsSearch(), 'Usage'); + await waitFor(() => { + expect(queryDashboard('general-data-sources')).not.toBeInTheDocument(); + expect(getDashboard('general-usage')).toBeInTheDocument(); + expect(queryDashboard('observability-backend-errors')).not.toBeInTheDocument(); + expect(queryDashboard('observability-backend-logs')).not.toBeInTheDocument(); + expect(queryDashboard('observability-frontend-errors')).not.toBeInTheDocument(); + expect(queryDashboard('observability-frontend-logs')).not.toBeInTheDocument(); + expect(getDashboard('usage-data-sources')).toBeInTheDocument(); + expect(getDashboard('usage-stats')).toBeInTheDocument(); + expect(getDashboard('usage-usage-overview')).toBeInTheDocument(); + expect(queryDashboard('frontend')).not.toBeInTheDocument(); + expect(queryDashboard('overview')).not.toBeInTheDocument(); + expect(queryDashboard('stats')).not.toBeInTheDocument(); + }); + }); + + it('Deduplicates the dashboards list', async () => { + await userEvents.click(getDashboardsExpand()); + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsCloudExpand()); + await userEvents.click(getResultApplicationsCloudDevSelect()); + await userEvents.click(getResultApplicationsCloudOpsSelect()); + await userEvents.click(getSelectorApply()); + await userEvents.click(getDashboardFolderExpand('Cardinality Management')); + await userEvents.click(getDashboardFolderExpand('Usage Insights')); + expect(queryAllDashboard('cardinality-management-labels')).toHaveLength(1); + expect(queryAllDashboard('cardinality-management-metrics')).toHaveLength(1); + expect(queryAllDashboard('cardinality-management-overview')).toHaveLength(1); + expect(queryAllDashboard('usage-insights-alertmanager')).toHaveLength(1); + expect(queryAllDashboard('usage-insights-data-sources')).toHaveLength(1); + expect(queryAllDashboard('usage-insights-metrics-ingestion')).toHaveLength(1); + expect(queryAllDashboard('usage-insights-overview')).toHaveLength(1); + expect(queryAllDashboard('usage-insights-query-errors')).toHaveLength(1); + expect(queryAllDashboard('billing-usage')).toHaveLength(1); + }); + + it('Shows a proper message when no scopes are selected', async () => { + await userEvents.click(getDashboardsExpand()); + expect(getNotFoundNoScopes()).toBeInTheDocument(); + expect(queryDashboardsSearch()).not.toBeInTheDocument(); + }); + + it('Does not show the input when there are no dashboards found for scope', async () => { + await userEvents.click(getDashboardsExpand()); + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultCloudSelect()); + await userEvents.click(getSelectorApply()); + expect(getNotFoundForScope()).toBeInTheDocument(); + expect(queryDashboardsSearch()).not.toBeInTheDocument(); + }); + + it('Shows the input and a message when there are no dashboards found for filter', async () => { + await userEvents.click(getDashboardsExpand()); + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.click(getSelectorApply()); + await userEvents.type(getDashboardsSearch(), 'unknown'); + await waitFor(() => { + expect(queryDashboardsSearch()).toBeInTheDocument(); + expect(getNotFoundForFilter()).toBeInTheDocument(); + }); + await userEvents.click(getNotFoundForFilterClear()); + await waitFor(() => { + expect(getDashboardsSearch().value).toBe(''); + }); + }); + }); + + describe('View mode', () => { + it('Enters view mode', async () => { + await act(async () => dashboardScene.onEnterEditMode()); + expect(scopesSelectorScene?.state?.isReadOnly).toEqual(true); + expect(scopesDashboardsScene?.state?.isPanelOpened).toEqual(false); + }); + + it('Closes selector on enter', async () => { + await userEvents.click(getSelectorInput()); + await act(async () => dashboardScene.onEnterEditMode()); + expect(querySelectorApply()).not.toBeInTheDocument(); + }); + + it('Closes dashboards list on enter', async () => { + await userEvents.click(getDashboardsExpand()); + await act(async () => dashboardScene.onEnterEditMode()); + expect(queryDashboardsContainer()).not.toBeInTheDocument(); + }); + + it('Does not open selector when view mode is active', async () => { + await act(async () => dashboardScene.onEnterEditMode()); + await userEvents.click(getSelectorInput()); + expect(querySelectorApply()).not.toBeInTheDocument(); + }); + + it('Disables the expand button when view mode is active', async () => { + await act(async () => dashboardScene.onEnterEditMode()); + expect(getDashboardsExpand()).toBeDisabled(); + }); + }); + + describe('Enrichers', () => { + it('Data requests', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getSelectorApply()); + await waitFor(() => { + const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; + expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'grafana') + ); + }); + + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.click(getSelectorApply()); + await waitFor(() => { + const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; + expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'grafana' || name === 'mimir') + ); + }); + + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getSelectorApply()); + await waitFor(() => { + const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; + expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'mimir') + ); + }); + }); + + it('Filters requests', async () => { + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getSelectorApply()); + await waitFor(() => { + expect(dashboardScene.enrichFiltersRequest().scopes).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'grafana') + ); + }); + + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsMimirSelect()); + await userEvents.click(getSelectorApply()); + await waitFor(() => { + expect(dashboardScene.enrichFiltersRequest().scopes).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'grafana' || name === 'mimir') + ); + }); + + await userEvents.click(getSelectorInput()); + await userEvents.click(getResultApplicationsGrafanaSelect()); + await userEvents.click(getSelectorApply()); + await waitFor(() => { + expect(dashboardScene.enrichFiltersRequest().scopes).toEqual( + mocksScopes.filter(({ metadata: { name } }) => name === 'mimir') + ); + }); + }); + }); + }); +}); diff --git a/public/app/features/scopes/tests/utils.test.ts b/public/app/features/scopes/tests/utils.test.ts new file mode 100644 index 00000000000..31444bddcd7 --- /dev/null +++ b/public/app/features/scopes/tests/utils.test.ts @@ -0,0 +1,366 @@ +import { filterFolders, groupDashboards } from '../internal/utils'; + +import { + alternativeDashboardWithRootFolder, + alternativeDashboardWithTwoFolders, + dashboardWithOneFolder, + dashboardWithoutFolder, + dashboardWithRootFolder, + dashboardWithRootFolderAndOtherFolder, + dashboardWithTwoFolders, +} from './utils/mocks'; + +describe('Scopes', () => { + describe('Utils', () => { + describe('groupDashboards', () => { + it('Assigns dashboards without groups to root folder', () => { + expect(groupDashboards([dashboardWithoutFolder])).toEqual({ + '': { + title: '', + isExpanded: true, + folders: {}, + dashboards: { + [dashboardWithoutFolder.spec.dashboard]: { + dashboard: dashboardWithoutFolder.spec.dashboard, + dashboardTitle: dashboardWithoutFolder.spec.dashboardTitle, + items: [dashboardWithoutFolder], + }, + }, + }, + }); + }); + + it('Assigns dashboards with root group to root folder', () => { + expect(groupDashboards([dashboardWithRootFolder])).toEqual({ + '': { + title: '', + isExpanded: true, + folders: {}, + dashboards: { + [dashboardWithRootFolder.spec.dashboard]: { + dashboard: dashboardWithRootFolder.spec.dashboard, + dashboardTitle: dashboardWithRootFolder.spec.dashboardTitle, + items: [dashboardWithRootFolder], + }, + }, + }, + }); + }); + + it('Merges folders from multiple dashboards', () => { + expect(groupDashboards([dashboardWithOneFolder, dashboardWithTwoFolders])).toEqual({ + '': { + title: '', + isExpanded: true, + folders: { + 'Folder 1': { + title: 'Folder 1', + isExpanded: false, + folders: {}, + dashboards: { + [dashboardWithOneFolder.spec.dashboard]: { + dashboard: dashboardWithOneFolder.spec.dashboard, + dashboardTitle: dashboardWithOneFolder.spec.dashboardTitle, + items: [dashboardWithOneFolder], + }, + [dashboardWithTwoFolders.spec.dashboard]: { + dashboard: dashboardWithTwoFolders.spec.dashboard, + dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle, + items: [dashboardWithTwoFolders], + }, + }, + }, + 'Folder 2': { + title: 'Folder 2', + isExpanded: false, + folders: {}, + dashboards: { + [dashboardWithTwoFolders.spec.dashboard]: { + dashboard: dashboardWithTwoFolders.spec.dashboard, + dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle, + items: [dashboardWithTwoFolders], + }, + }, + }, + }, + dashboards: {}, + }, + }); + }); + + it('Merges scopes from multiple dashboards', () => { + expect(groupDashboards([dashboardWithTwoFolders, alternativeDashboardWithTwoFolders])).toEqual({ + '': { + title: '', + isExpanded: true, + folders: { + 'Folder 1': { + title: 'Folder 1', + isExpanded: false, + folders: {}, + dashboards: { + [dashboardWithTwoFolders.spec.dashboard]: { + dashboard: dashboardWithTwoFolders.spec.dashboard, + dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle, + items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], + }, + }, + }, + 'Folder 2': { + title: 'Folder 2', + isExpanded: false, + folders: {}, + dashboards: { + [dashboardWithTwoFolders.spec.dashboard]: { + dashboard: dashboardWithTwoFolders.spec.dashboard, + dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle, + items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], + }, + }, + }, + }, + dashboards: {}, + }, + }); + }); + + it('Matches snapshot', () => { + expect( + groupDashboards([ + dashboardWithoutFolder, + dashboardWithOneFolder, + dashboardWithTwoFolders, + alternativeDashboardWithTwoFolders, + dashboardWithRootFolder, + alternativeDashboardWithRootFolder, + dashboardWithRootFolderAndOtherFolder, + ]) + ).toEqual({ + '': { + dashboards: { + [dashboardWithRootFolderAndOtherFolder.spec.dashboard]: { + dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard, + dashboardTitle: dashboardWithRootFolderAndOtherFolder.spec.dashboardTitle, + items: [dashboardWithRootFolderAndOtherFolder], + }, + [dashboardWithRootFolder.spec.dashboard]: { + dashboard: dashboardWithRootFolder.spec.dashboard, + dashboardTitle: dashboardWithRootFolder.spec.dashboardTitle, + items: [dashboardWithRootFolder, alternativeDashboardWithRootFolder], + }, + [dashboardWithoutFolder.spec.dashboard]: { + dashboard: dashboardWithoutFolder.spec.dashboard, + dashboardTitle: dashboardWithoutFolder.spec.dashboardTitle, + items: [dashboardWithoutFolder], + }, + }, + folders: { + 'Folder 1': { + dashboards: { + [dashboardWithOneFolder.spec.dashboard]: { + dashboard: dashboardWithOneFolder.spec.dashboard, + dashboardTitle: dashboardWithOneFolder.spec.dashboardTitle, + items: [dashboardWithOneFolder], + }, + [dashboardWithTwoFolders.spec.dashboard]: { + dashboard: dashboardWithTwoFolders.spec.dashboard, + dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle, + items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], + }, + }, + folders: {}, + isExpanded: false, + title: 'Folder 1', + }, + 'Folder 2': { + dashboards: { + [dashboardWithTwoFolders.spec.dashboard]: { + dashboard: dashboardWithTwoFolders.spec.dashboard, + dashboardTitle: dashboardWithTwoFolders.spec.dashboardTitle, + items: [dashboardWithTwoFolders, alternativeDashboardWithTwoFolders], + }, + }, + folders: {}, + isExpanded: false, + title: 'Folder 2', + }, + 'Folder 3': { + dashboards: { + [dashboardWithRootFolderAndOtherFolder.spec.dashboard]: { + dashboard: dashboardWithRootFolderAndOtherFolder.spec.dashboard, + dashboardTitle: dashboardWithRootFolderAndOtherFolder.spec.dashboardTitle, + items: [dashboardWithRootFolderAndOtherFolder], + }, + }, + folders: {}, + isExpanded: false, + title: 'Folder 3', + }, + }, + isExpanded: true, + title: '', + }, + }); + }); + }); + + describe('filterFolders', () => { + it('Shows folders matching criteria', () => { + expect( + filterFolders( + { + '': { + title: '', + isExpanded: true, + folders: { + 'Folder 1': { + title: 'Folder 1', + isExpanded: false, + folders: {}, + dashboards: { + 'Dashboard ID': { + dashboard: 'Dashboard ID', + dashboardTitle: 'Dashboard Title', + items: [], + }, + }, + }, + 'Folder 2': { + title: 'Folder 2', + isExpanded: true, + folders: {}, + dashboards: { + 'Dashboard ID': { + dashboard: 'Dashboard ID', + dashboardTitle: 'Dashboard Title', + items: [], + }, + }, + }, + }, + dashboards: { + 'Dashboard ID': { + dashboard: 'Dashboard ID', + dashboardTitle: 'Dashboard Title', + items: [], + }, + }, + }, + }, + 'Folder' + ) + ).toEqual({ + '': { + title: '', + isExpanded: true, + folders: { + 'Folder 1': { + title: 'Folder 1', + isExpanded: true, + folders: {}, + dashboards: { + 'Dashboard ID': { + dashboard: 'Dashboard ID', + dashboardTitle: 'Dashboard Title', + items: [], + }, + }, + }, + 'Folder 2': { + title: 'Folder 2', + isExpanded: true, + folders: {}, + dashboards: { + 'Dashboard ID': { + dashboard: 'Dashboard ID', + dashboardTitle: 'Dashboard Title', + items: [], + }, + }, + }, + }, + dashboards: {}, + }, + }); + }); + + it('Shows dashboards matching criteria', () => { + expect( + filterFolders( + { + '': { + title: '', + isExpanded: true, + folders: { + 'Folder 1': { + title: 'Folder 1', + isExpanded: false, + folders: {}, + dashboards: { + 'Dashboard ID': { + dashboard: 'Dashboard ID', + dashboardTitle: 'Dashboard Title', + items: [], + }, + }, + }, + 'Folder 2': { + title: 'Folder 2', + isExpanded: true, + folders: {}, + dashboards: { + 'Random ID': { + dashboard: 'Random ID', + dashboardTitle: 'Random Title', + items: [], + }, + }, + }, + }, + dashboards: { + 'Dashboard ID': { + dashboard: 'Dashboard ID', + dashboardTitle: 'Dashboard Title', + items: [], + }, + 'Random ID': { + dashboard: 'Random ID', + dashboardTitle: 'Random Title', + items: [], + }, + }, + }, + }, + 'dash' + ) + ).toEqual({ + '': { + title: '', + isExpanded: true, + folders: { + 'Folder 1': { + title: 'Folder 1', + isExpanded: true, + folders: {}, + dashboards: { + 'Dashboard ID': { + dashboard: 'Dashboard ID', + dashboardTitle: 'Dashboard Title', + items: [], + }, + }, + }, + }, + dashboards: { + 'Dashboard ID': { + dashboard: 'Dashboard ID', + dashboardTitle: 'Dashboard Title', + items: [], + }, + }, + }, + }); + }); + }); + }); +}); diff --git a/public/app/features/scopes/tests/utils/mocks.ts b/public/app/features/scopes/tests/utils/mocks.ts new file mode 100644 index 00000000000..4c7cac525b1 --- /dev/null +++ b/public/app/features/scopes/tests/utils/mocks.ts @@ -0,0 +1,446 @@ +import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data'; + +import * as api from '../../internal/api'; + +export const mocksScopes: Scope[] = [ + { + metadata: { name: 'cloud' }, + spec: { + title: 'Cloud', + type: 'indexHelper', + description: 'redundant label filter but makes queries faster', + category: 'indexHelpers', + filters: [{ key: 'cloud', value: '.*', operator: 'regex-match' }], + }, + }, + { + metadata: { name: 'dev' }, + spec: { + title: 'Dev', + type: 'cloud', + description: 'Dev', + category: 'cloud', + filters: [{ key: 'cloud', value: 'dev', operator: 'equals' }], + }, + }, + { + metadata: { name: 'ops' }, + spec: { + title: 'Ops', + type: 'cloud', + description: 'Ops', + category: 'cloud', + filters: [{ key: 'cloud', value: 'ops', operator: 'equals' }], + }, + }, + { + metadata: { name: 'prod' }, + spec: { + title: 'Prod', + type: 'cloud', + description: 'Prod', + category: 'cloud', + filters: [{ key: 'cloud', value: 'prod', operator: 'equals' }], + }, + }, + { + metadata: { name: 'grafana' }, + spec: { + title: 'Grafana', + type: 'app', + description: 'Grafana', + category: 'apps', + filters: [{ key: 'app', value: 'grafana', operator: 'equals' }], + }, + }, + { + metadata: { name: 'mimir' }, + spec: { + title: 'Mimir', + type: 'app', + description: 'Mimir', + category: 'apps', + filters: [{ key: 'app', value: 'mimir', operator: 'equals' }], + }, + }, + { + metadata: { name: 'loki' }, + spec: { + title: 'Loki', + type: 'app', + description: 'Loki', + category: 'apps', + filters: [{ key: 'app', value: 'loki', operator: 'equals' }], + }, + }, + { + metadata: { name: 'tempo' }, + spec: { + title: 'Tempo', + type: 'app', + description: 'Tempo', + category: 'apps', + filters: [{ key: 'app', value: 'tempo', operator: 'equals' }], + }, + }, +] as const; + +const dashboardBindingsGenerator = ( + scopes: string[], + dashboards: Array<{ dashboardTitle: string; dashboardKey?: string; groups?: string[] }> +) => + scopes.reduce((scopeAcc, scopeTitle) => { + const scope = scopeTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-'); + + return [ + ...scopeAcc, + ...dashboards.reduce((acc, { dashboardTitle, groups, dashboardKey }, idx) => { + dashboardKey = dashboardKey ?? dashboardTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-'); + const group = !groups + ? '' + : groups.length === 1 + ? groups[0] === '' + ? '' + : `${groups[0].toLowerCase().replaceAll(' ', '-').replaceAll('/', '-')}-` + : `multiple${idx}-`; + const dashboard = `${group}${dashboardKey}`; + + return [ + ...acc, + { + metadata: { name: `${scope}-${dashboard}` }, + spec: { + dashboard, + dashboardTitle, + scope, + groups, + }, + }, + ]; + }, []), + ]; + }, []); + +export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [ + ...dashboardBindingsGenerator( + ['Grafana'], + [ + { dashboardTitle: 'Data Sources', groups: ['General'] }, + { dashboardTitle: 'Usage', groups: ['General'] }, + { dashboardTitle: 'Frontend Errors', groups: ['Observability'] }, + { dashboardTitle: 'Frontend Logs', groups: ['Observability'] }, + { dashboardTitle: 'Backend Errors', groups: ['Observability'] }, + { dashboardTitle: 'Backend Logs', groups: ['Observability'] }, + { dashboardTitle: 'Usage Overview', groups: ['Usage'] }, + { dashboardTitle: 'Data Sources', groups: ['Usage'] }, + { dashboardTitle: 'Stats', groups: ['Usage'] }, + { dashboardTitle: 'Overview', groups: [''] }, + { dashboardTitle: 'Frontend' }, + { dashboardTitle: 'Stats' }, + ] + ), + ...dashboardBindingsGenerator( + ['Loki', 'Tempo', 'Mimir'], + [ + { dashboardTitle: 'Ingester', groups: ['Components', 'Investigations'] }, + { dashboardTitle: 'Distributor', groups: ['Components', 'Investigations'] }, + { dashboardTitle: 'Compacter', groups: ['Components', 'Investigations'] }, + { dashboardTitle: 'Datasource Errors', groups: ['Observability', 'Investigations'] }, + { dashboardTitle: 'Datasource Logs', groups: ['Observability', 'Investigations'] }, + { dashboardTitle: 'Overview' }, + { dashboardTitle: 'Stats', dashboardKey: 'another-stats' }, + ] + ), + ...dashboardBindingsGenerator( + ['Dev', 'Ops', 'Prod'], + [ + { dashboardTitle: 'Overview', groups: ['Cardinality Management'] }, + { dashboardTitle: 'Metrics', groups: ['Cardinality Management'] }, + { dashboardTitle: 'Labels', groups: ['Cardinality Management'] }, + { dashboardTitle: 'Overview', groups: ['Usage Insights'] }, + { dashboardTitle: 'Data Sources', groups: ['Usage Insights'] }, + { dashboardTitle: 'Query Errors', groups: ['Usage Insights'] }, + { dashboardTitle: 'Alertmanager', groups: ['Usage Insights'] }, + { dashboardTitle: 'Metrics Ingestion', groups: ['Usage Insights'] }, + { dashboardTitle: 'Billing/Usage' }, + ] + ), +] as const; + +export const mocksNodes: Array = [ + { + parent: '', + metadata: { name: 'applications' }, + spec: { + nodeType: 'container', + title: 'Applications', + description: 'Application Scopes', + }, + }, + { + parent: '', + metadata: { name: 'cloud' }, + spec: { + nodeType: 'container', + title: 'Cloud', + description: 'Cloud Scopes', + disableMultiSelect: true, + linkType: 'scope', + linkId: 'cloud', + }, + }, + { + parent: 'applications', + metadata: { name: 'applications-grafana' }, + spec: { + nodeType: 'leaf', + title: 'Grafana', + description: 'Grafana', + linkType: 'scope', + linkId: 'grafana', + }, + }, + { + parent: 'applications', + metadata: { name: 'applications-mimir' }, + spec: { + nodeType: 'leaf', + title: 'Mimir', + description: 'Mimir', + linkType: 'scope', + linkId: 'mimir', + }, + }, + { + parent: 'applications', + metadata: { name: 'applications-loki' }, + spec: { + nodeType: 'leaf', + title: 'Loki', + description: 'Loki', + linkType: 'scope', + linkId: 'loki', + }, + }, + { + parent: 'applications', + metadata: { name: 'applications-tempo' }, + spec: { + nodeType: 'leaf', + title: 'Tempo', + description: 'Tempo', + linkType: 'scope', + linkId: 'tempo', + }, + }, + { + parent: 'applications', + metadata: { name: 'applications-cloud' }, + spec: { + nodeType: 'container', + title: 'Cloud', + description: 'Application/Cloud Scopes', + linkType: 'scope', + linkId: 'cloud', + }, + }, + { + parent: 'applications-cloud', + metadata: { name: 'applications-cloud-dev' }, + spec: { + nodeType: 'leaf', + title: 'Dev', + description: 'Dev', + linkType: 'scope', + linkId: 'dev', + }, + }, + { + parent: 'applications-cloud', + metadata: { name: 'applications-cloud-ops' }, + spec: { + nodeType: 'leaf', + title: 'Ops', + description: 'Ops', + linkType: 'scope', + linkId: 'ops', + }, + }, + { + parent: 'applications-cloud', + metadata: { name: 'applications-cloud-prod' }, + spec: { + nodeType: 'leaf', + title: 'Prod', + description: 'Prod', + linkType: 'scope', + linkId: 'prod', + }, + }, + { + parent: 'cloud', + metadata: { name: 'cloud-dev' }, + spec: { + nodeType: 'leaf', + title: 'Dev', + description: 'Dev', + linkType: 'scope', + linkId: 'dev', + }, + }, + { + parent: 'cloud', + metadata: { name: 'cloud-ops' }, + spec: { + nodeType: 'leaf', + title: 'Ops', + description: 'Ops', + linkType: 'scope', + linkId: 'ops', + }, + }, + { + parent: 'cloud', + metadata: { name: 'cloud-prod' }, + spec: { + nodeType: 'leaf', + title: 'Prod', + description: 'Prod', + linkType: 'scope', + linkId: 'prod', + }, + }, + { + parent: 'cloud', + metadata: { name: 'cloud-applications' }, + spec: { + nodeType: 'container', + title: 'Applications', + description: 'Cloud/Application Scopes', + }, + }, + { + parent: 'cloud-applications', + metadata: { name: 'cloud-applications-grafana' }, + spec: { + nodeType: 'leaf', + title: 'Grafana', + description: 'Grafana', + linkType: 'scope', + linkId: 'grafana', + }, + }, + { + parent: 'cloud-applications', + metadata: { name: 'cloud-applications-mimir' }, + spec: { + nodeType: 'leaf', + title: 'Mimir', + description: 'Mimir', + linkType: 'scope', + linkId: 'mimir', + }, + }, + { + parent: 'cloud-applications', + metadata: { name: 'cloud-applications-loki' }, + spec: { + nodeType: 'leaf', + title: 'Loki', + description: 'Loki', + linkType: 'scope', + linkId: 'loki', + }, + }, + { + parent: 'cloud-applications', + metadata: { name: 'cloud-applications-tempo' }, + spec: { + nodeType: 'leaf', + title: 'Tempo', + description: 'Tempo', + linkType: 'scope', + linkId: 'tempo', + }, + }, +] as const; + +export const fetchNodesSpy = jest.spyOn(api, 'fetchNodes'); +export const fetchScopeSpy = jest.spyOn(api, 'fetchScope'); +export const fetchSelectedScopesSpy = jest.spyOn(api, 'fetchSelectedScopes'); +export const fetchDashboardsSpy = jest.spyOn(api, 'fetchDashboards'); + +export const getMock = jest + .fn() + .mockImplementation((url: string, params: { parent: string; scope: string[]; query?: string }) => { + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) { + return { + items: mocksNodes.filter( + ({ parent, spec: { title } }) => + parent === params.parent && title.toLowerCase().includes((params.query ?? '').toLowerCase()) + ), + }; + } + + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) { + const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', ''); + + return mocksScopes.find((scope) => scope.metadata.name.toLowerCase() === name.toLowerCase()) ?? {}; + } + + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) { + return { + items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) => + params.scope.includes(bindingScope) + ), + }; + } + + if (url.startsWith('/api/dashboards/uid/')) { + return {}; + } + + if (url.startsWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/')) { + return { + metadata: { + name: '1', + }, + }; + } + + return {}; + }); + +const generateScopeDashboardBinding = (dashboardTitle: string, groups?: string[], dashboardId?: string) => ({ + metadata: { name: `${dashboardTitle}-name` }, + spec: { + dashboard: `${dashboardId ?? dashboardTitle}-dashboard`, + dashboardTitle, + scope: `${dashboardTitle}-scope`, + groups, + }, +}); + +export const dashboardWithoutFolder: ScopeDashboardBinding = generateScopeDashboardBinding('Without Folder'); +export const dashboardWithOneFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With one folder', [ + 'Folder 1', +]); +export const dashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding('With two folders', [ + 'Folder 1', + 'Folder 2', +]); +export const alternativeDashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding( + 'Alternative with two folders', + ['Folder 1', 'Folder 2'], + 'With two folders' +); +export const dashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With root folder', ['']); +export const alternativeDashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding( + 'Alternative With root folder', + [''], + 'With root folder' +); +export const dashboardWithRootFolderAndOtherFolder: ScopeDashboardBinding = generateScopeDashboardBinding( + 'With root folder and other folder', + ['', 'Folder 3'] +); diff --git a/public/app/features/scopes/tests/utils/render.tsx b/public/app/features/scopes/tests/utils/render.tsx new file mode 100644 index 00000000000..51986d242e1 --- /dev/null +++ b/public/app/features/scopes/tests/utils/render.tsx @@ -0,0 +1,92 @@ +import { KBarProvider } from 'kbar'; +import { render } from 'test/test-utils'; + +import { + AdHocFiltersVariable, + behaviors, + GroupByVariable, + sceneGraph, + SceneGridItem, + SceneGridLayout, + SceneQueryRunner, + SceneTimeRange, + SceneVariableSet, + VizPanel, +} from '@grafana/scenes'; +import { AppChrome } from 'app/core/components/AppChrome/AppChrome'; +import { DashboardControls } from 'app/features/dashboard-scene/scene//DashboardControls'; +import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; + +import { ScopesFacade } from '../../ScopesFacadeScene'; +import { scopesDashboardsScene, scopesSelectorScene } from '../../instance'; +import { getInitialDashboardsState } from '../../internal/ScopesDashboardsScene'; +import { initialSelectorState } from '../../internal/ScopesSelectorScene'; +import { DASHBOARDS_OPENED_KEY } from '../../internal/const'; + +export function buildTestScene(overrides: Partial = {}) { + return new DashboardScene({ + title: 'hello', + uid: 'dash-1', + description: 'hello description', + tags: ['tag1', 'tag2'], + editable: true, + $timeRange: new SceneTimeRange({ + timeZone: 'browser', + }), + controls: new DashboardControls({}), + $behaviors: [ + new behaviors.CursorSync({}), + new ScopesFacade({ + handler: (facade) => sceneGraph.getTimeRange(facade).onRefresh(), + }), + ], + $variables: new SceneVariableSet({ + variables: [ + new AdHocFiltersVariable({ + name: 'adhoc', + datasource: { uid: 'my-ds-uid' }, + }), + new GroupByVariable({ + name: 'groupby', + datasource: { uid: 'my-ds-uid' }, + }), + ], + }), + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 300, + height: 300, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }), + }), + ], + }), + ...overrides, + }); +} + +export function renderDashboard(dashboardScene: DashboardScene) { + return render( + + + + + + ); +} + +export function resetScenes() { + scopesSelectorScene?.setState(initialSelectorState); + + localStorage.removeItem(DASHBOARDS_OPENED_KEY); + + scopesDashboardsScene?.setState(getInitialDashboardsState()); +} diff --git a/public/app/features/scopes/tests/utils/selectors.ts b/public/app/features/scopes/tests/utils/selectors.ts new file mode 100644 index 00000000000..5ca61c056f6 --- /dev/null +++ b/public/app/features/scopes/tests/utils/selectors.ts @@ -0,0 +1,92 @@ +import { screen } from '@testing-library/react'; + +const selectors = { + tree: { + search: 'scopes-tree-search', + headline: 'scopes-tree-headline', + select: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-checkbox`, + radio: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-radio`, + expand: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-expand`, + title: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-title`, + }, + selector: { + input: 'scopes-selector-input', + container: 'scopes-selector-container', + loading: 'scopes-selector-loading', + apply: 'scopes-selector-apply', + cancel: 'scopes-selector-cancel', + }, + dashboards: { + expand: 'scopes-dashboards-expand', + container: 'scopes-dashboards-container', + search: 'scopes-dashboards-search', + loading: 'scopes-dashboards-loading', + dashboard: (uid: string) => `scopes-dashboards-${uid}`, + dashboardExpand: (uid: string) => `scopes-dashboards-${uid}-expand`, + notFoundNoScopes: 'scopes-dashboards-notFoundNoScopes', + notFoundForScope: 'scopes-dashboards-notFoundForScope', + notFoundForFilter: 'scopes-dashboards-notFoundForFilter', + notFoundForFilterClear: 'scopes-dashboards-notFoundForFilter-clear', + }, +}; + +export const getSelectorInput = () => screen.getByTestId(selectors.selector.input); +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 getDashboardsExpand = () => screen.getByTestId(selectors.dashboards.expand); +export const queryDashboardsContainer = () => screen.queryByTestId(selectors.dashboards.container); +export const queryDashboardsSearch = () => screen.queryByTestId(selectors.dashboards.search); +export const getDashboardsSearch = () => screen.getByTestId(selectors.dashboards.search); +export const queryDashboardFolderExpand = (uid: string) => + screen.queryByTestId(selectors.dashboards.dashboardExpand(uid)); +export const getDashboardFolderExpand = (uid: string) => screen.getByTestId(selectors.dashboards.dashboardExpand(uid)); +export const queryAllDashboard = (uid: string) => screen.queryAllByTestId(selectors.dashboards.dashboard(uid)); +export const queryDashboard = (uid: string) => screen.queryByTestId(selectors.dashboards.dashboard(uid)); +export const getDashboard = (uid: string) => screen.getByTestId(selectors.dashboards.dashboard(uid)); +export const getNotFoundNoScopes = () => screen.getByTestId(selectors.dashboards.notFoundNoScopes); +export const getNotFoundForScope = () => screen.getByTestId(selectors.dashboards.notFoundForScope); +export const getNotFoundForFilter = () => screen.getByTestId(selectors.dashboards.notFoundForFilter); +export const getNotFoundForFilterClear = () => screen.getByTestId(selectors.dashboards.notFoundForFilterClear); + +export const getTreeSearch = () => screen.getByTestId(selectors.tree.search); +export const getTreeHeadline = () => screen.getByTestId(selectors.tree.headline); +export const getResultApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications', 'result')); +export const queryResultApplicationsGrafanaTitle = () => + screen.queryByTestId(selectors.tree.title('applications-grafana', 'result')); +export const getResultApplicationsGrafanaTitle = () => + screen.getByTestId(selectors.tree.title('applications-grafana', 'result')); +export const getResultApplicationsGrafanaSelect = () => + screen.getByTestId(selectors.tree.select('applications-grafana', 'result')); +export const queryPersistedApplicationsGrafanaTitle = () => + screen.queryByTestId(selectors.tree.title('applications-grafana', 'persisted')); +export const queryResultApplicationsMimirTitle = () => + screen.queryByTestId(selectors.tree.title('applications-mimir', 'result')); +export const getResultApplicationsMimirTitle = () => + screen.getByTestId(selectors.tree.title('applications-mimir', 'result')); +export const getResultApplicationsMimirSelect = () => + screen.getByTestId(selectors.tree.select('applications-mimir', 'result')); +export const queryPersistedApplicationsMimirTitle = () => + screen.queryByTestId(selectors.tree.title('applications-mimir', 'persisted')); +export const getPersistedApplicationsMimirTitle = () => + screen.getByTestId(selectors.tree.title('applications-mimir', 'persisted')); +export const getPersistedApplicationsMimirSelect = () => + screen.getByTestId(selectors.tree.select('applications-mimir', 'persisted')); +export const queryResultApplicationsCloudTitle = () => + screen.queryByTestId(selectors.tree.title('applications-cloud', 'result')); +export const getResultApplicationsCloudSelect = () => + screen.getByTestId(selectors.tree.select('applications-cloud', 'result')); +export const getResultApplicationsCloudExpand = () => + screen.getByTestId(selectors.tree.expand('applications-cloud', 'result')); +export const getResultApplicationsCloudDevSelect = () => + screen.getByTestId(selectors.tree.select('applications-cloud-dev', 'result')); +export const getResultApplicationsCloudOpsSelect = () => + screen.getByTestId(selectors.tree.select('applications-cloud-ops', 'result')); + +export const getResultCloudSelect = () => screen.getByTestId(selectors.tree.select('cloud', 'result')); +export const getResultCloudExpand = () => screen.getByTestId(selectors.tree.expand('cloud', 'result')); +export const getResultCloudDevRadio = () => + screen.getByTestId(selectors.tree.radio('cloud-dev', 'result')); +export const getResultCloudOpsRadio = () => + screen.getByTestId(selectors.tree.radio('cloud-ops', 'result')); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index de0f91050ee..7614f6741d7 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2129,6 +2129,8 @@ }, "scopes": { "dashboards": { + "collapse": "Collapse", + "expand": "Expand", "loading": "Loading dashboards", "noResultsForFilter": "No results found for your query", "noResultsForFilterClear": "Clear search", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 1bd43c83b52..08a11b6551e 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2129,6 +2129,8 @@ }, "scopes": { "dashboards": { + "collapse": "Cőľľäpşę", + "expand": "Ēχpäʼnđ", "loading": "Ŀőäđįʼnģ đäşĥþőäřđş", "noResultsForFilter": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy", "noResultsForFilterClear": "Cľęäř şęäřčĥ",