diff --git a/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts b/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts index 1e21ac8eba6..038fe099eeb 100644 --- a/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts +++ b/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts @@ -169,7 +169,7 @@ test.describe( await setScopes(page); - await expect(scopesSelectorInput).toHaveValue(/.+/); + await expect(scopesSelectorInput).toHaveAttribute('data-value', /.+/); expect(await adHocFilterPills.count()).toBe(3); diff --git a/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts b/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts index 008ca89ef7f..008e1e5538c 100644 --- a/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts +++ b/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts @@ -43,7 +43,7 @@ test.describe( await setScopes(page); - await expect(scopeSelectorInput).toHaveValue(/.+/); + await expect(scopeSelectorInput).toHaveAttribute('data-value', /.+/); const firstDbName = await scopesDashboards.first().textContent(); const scopeDashboardsCount = await scopesDashboards.count(); @@ -60,7 +60,7 @@ test.describe( await setScopes(page); - await expect(scopeSelectorInput).toHaveValue(/.+/); + await expect(scopeSelectorInput).toHaveAttribute('data-value', /.+/); // assert the panel is visible and has the correct value const markdownContent = await getMarkdownHTMLContent(dashboardPage, selectors); @@ -94,7 +94,7 @@ test.describe( await setScopes(page, { title: 'CUJ Dashboard 3', uid: NAVIGATE_TO }); - await expect(scopeSelectorInput).toHaveValue(/.+/); + await expect(scopeSelectorInput).toHaveAttribute('data-value', /.+/); const pills = await adhocFilterPills.allTextContents(); const processedPills = pills @@ -138,7 +138,7 @@ test.describe( await setScopes(page, { title: 'CUJ Dashboard 2', uid: 'cuj-dashboard-2' }); - await expect(scopeSelectorInput).toHaveValue(/.+/); + await expect(scopeSelectorInput).toHaveAttribute('data-value', /.+/); const pillCount = await adhocFilterPills.count(); const pillTexts = await adhocFilterPills.allTextContents(); diff --git a/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts b/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts index 7d8d202cf41..54ec3ca7a8b 100644 --- a/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts +++ b/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts @@ -40,7 +40,7 @@ test.describe( await test.step('1.View and select any scope', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); - expect.soft(scopesSelector).toHaveValue(''); + expect.soft(scopesSelector).toHaveAttribute('data-value', ''); const scopes = testScopes(); await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version @@ -63,14 +63,13 @@ test.describe( await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); //used only in mocked scopes version - expect.soft(scopesSelector).toHaveValue(scopeTitle); + expect.soft(scopesSelector).toHaveAttribute('data-value', scopeTitle); }); await test.step('2.Select a scope across multiple types of production entities', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); - expect.soft(scopesSelector).toHaveValue(''); - + expect.soft(scopesSelector).toHaveAttribute('data-value', ''); const scopes = testScopes(); await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version @@ -94,14 +93,14 @@ test.describe( await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); //used only in mocked scopes version - await expect.soft(scopesSelector).toHaveValue(scopeTitles.join(' + ')); + await expect.soft(scopesSelector).toHaveAttribute('data-value', scopeTitles.join(' + ')); }); await test.step('3.View and select a recently viewed scope', async () => { // this step depends on the previous ones because they set recent scopes await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); - expect.soft(scopesSelector).toHaveValue(''); + expect.soft(scopesSelector).toHaveAttribute('data-value', ''); const scopes = testScopes(); await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version @@ -114,13 +113,13 @@ test.describe( await recentScope.click(); - await expect.soft(scopesSelector).toHaveValue(scopeName!.replace(', ', ' + ')); + await expect.soft(scopesSelector).toHaveAttribute('data-value', scopeName!.replace(', ', ' + ')); }); await test.step('4.View and select a scope configured by any team', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); - expect.soft(scopesSelector).toHaveValue(''); + expect.soft(scopesSelector).toHaveAttribute('data-value', ''); const scopes = testScopes(); await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); @@ -143,7 +142,7 @@ test.describe( await applyScopes(page, USE_LIVE_DATA ? undefined : []); //used only in mocked scopes version - expect.soft(scopesSelector).toHaveValue(new RegExp(`^${scopeTitle}`)); + expect.soft(scopesSelector).toHaveAttribute('data-value', new RegExp(`^${scopeTitle}`)); }); await test.step('5.View pre-completed production entity values as I type', async () => { diff --git a/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts b/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts index 1e3d5f36711..c0e6e55b5b0 100644 --- a/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts +++ b/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts @@ -161,6 +161,7 @@ test.describe('Scope Redirect Functionality', () => { await test.step('Deselect scopes and verify no redirect', async () => { // Click the clear button to remove all scopes + await page.getByTestId('scopes-selector-input').hover(); await page.getByTestId('scopes-selector-input-clear').click(); // Should stay on the same dashboard (cuj-dashboard-3), not redirect diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index bf3681d9361..d61dfdb46de 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -12,6 +12,7 @@ import { useGrafana } from 'app/core/context/GrafanaContext'; import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth'; import { HOME_NAV_ID } from 'app/core/reducers/navModel'; import { contextSrv } from 'app/core/services/context_srv'; +import { ScopesSelector } from 'app/features/scopes/selector/ScopesSelector'; import { useSelector } from 'app/types/store'; import { Branding } from '../../Branding/Branding'; @@ -60,6 +61,8 @@ export const SingleTopBar = memo(function SingleTopBar({ const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); const unifiedHistoryEnabled = config.featureToggles.unifiedHistory; const isSmallScreen = !useMediaQueryMinWidth('sm'); + const isLargeScreen = useMediaQueryMinWidth('lg'); + const topLevelScopes = !showToolbarLevel && isLargeScreen && scopes?.state.enabled; return ( <> @@ -78,6 +81,7 @@ export const SingleTopBar = memo(function SingleTopBar({ )} + {topLevelScopes ? : undefined} {!showToolbarLevel && breadcrumbActions} diff --git a/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx b/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx index 3f36302a7f5..7d177c9b4a8 100644 --- a/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx +++ b/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx @@ -112,18 +112,5 @@ const getStyles = (theme: GrafanaTheme2) => { '&:focus-visible': getFocusStyles(theme), }, ]), - - button: css({ - // height: 32, - width: '100%', - textAlign: 'center', - - '> *': { - width: '100%', - textAlign: 'center', - justifyContent: 'center', - gap: '1ch', - }, - }), }; }; diff --git a/public/app/core/components/AppChrome/TopBar/useChromeHeaderHeight.ts b/public/app/core/components/AppChrome/TopBar/useChromeHeaderHeight.ts index fa3555741b6..3b1763b1901 100644 --- a/public/app/core/components/AppChrome/TopBar/useChromeHeaderHeight.ts +++ b/public/app/core/components/AppChrome/TopBar/useChromeHeaderHeight.ts @@ -15,7 +15,7 @@ export function useChromeHeaderLevels() { const state = chrome.state.getValue(); const scopes = useScopes(); - const isLargeScreen = useMediaQueryMinWidth('xl'); + const isLargeScreen = useMediaQueryMinWidth('lg'); const [headerLevels, setHeaderLevels] = useState( getHeaderLevelsGivenState(state, scopes?.state.enabled, isLargeScreen) @@ -46,8 +46,8 @@ function getHeaderLevelsGivenState( return 0; } - // Always use two levels scopes is enabled - if (scopesEnabled) { + // // Use 2 levels if scopes is enabled on smaller screens + if (scopesEnabled && !isLargeScreen) { return 2; } diff --git a/public/app/features/scopes/selector/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx index 39b9edd8295..aaf54adb8d0 100644 --- a/public/app/features/scopes/selector/ScopesInput.tsx +++ b/public/app/features/scopes/selector/ScopesInput.tsx @@ -1,10 +1,9 @@ -import { css } from '@emotion/css'; -import { useEffect, useMemo, useState } from 'react'; -import Skeleton from 'react-loading-skeleton'; +import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { IconButton, Input, Tooltip, useStyles2 } from '@grafana/ui'; +import { t, Trans } from '@grafana/i18n'; +import { getInputStyles, Icon, LinkButton, Spinner, Tooltip, useStyles2, Text, Stack } from '@grafana/ui'; +import { getFocusStyles } from '@grafana/ui/internal'; import { getPathOfNode } from './scopesTreeUtils'; import { NodesMap, ScopesMap, SelectedScope } from './types'; @@ -32,11 +31,9 @@ export function ScopesInput({ onInputClick, onRemoveAllClick, }: ScopesInputProps) { - const [tooltipVisible, setTooltipVisible] = useState(false); - const scopeNodeId = appliedScopes[0]?.scopeNodeId; const parentNodeIdFromUrl = appliedScopes[0]?.parentNodeId; - + const styles = useStyles2(getStyles); const { node: scopeNode, isLoading: scopeNodeLoading } = useScopeNode(scopeNodeId); // Get parent from scope node if available, otherwise use parentNodeId from URL (for backward compatibility) @@ -44,70 +41,61 @@ export function ScopesInput({ const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId); // Prioritize scope node subtitle over parent node title - const displayTitle = scopeNode?.spec.subTitle ?? parentNode?.spec.title; + const displayTitle = + scopeNode?.spec.subTitle ?? parentNode?.spec.title ?? (appliedScopes.length > 0 ? 'Mimir' : undefined); const isLoadingTitle = scopeNodeLoading || parentNodeLoading; + const placeholderText = t('scopes.selector.input.placeholder', 'No scopes'); - useEffect(() => { - setTooltipVisible(false); - }, [appliedScopes]); - - const tooltipContent = - appliedScopes.length > 0 ? : <>; - - const scopesTitles = useMemo( - () => - appliedScopes - .map( - (s) => - // If we are still loading the scope data just show the id - scopes[s.scopeId]?.spec.title || s.scopeId - ) - .join(' + '), - [appliedScopes, scopes] + const tooltipContent = ( + ); - const parentNodePrefix = useMemo( - () => - isLoadingTitle ? : displayTitle ? {displayTitle}: : undefined, - [isLoadingTitle, displayTitle] - ); + const scopesTitles = appliedScopes + .map( + (s) => + // If we are still loading the scope data just show the id + scopes[s.scopeId]?.spec.title || s.scopeId + ) + .join(' + '); - const input = useMemo( - () => ( - 0 && !disabled ? ( - onRemoveAllClick()} - /> - ) : undefined - } - onMouseOver={() => setTooltipVisible(true)} - onMouseOut={() => setTooltipVisible(false)} - onClick={() => { - if (!disabled) { - onInputClick(); - } - }} - /> - ), - [disabled, loading, onInputClick, onRemoveAllClick, appliedScopes, scopesTitles, parentNodePrefix] - ); + const onClick = () => { + if (!disabled) { + onInputClick(); + } + }; return ( - - {input} + + ); } @@ -131,13 +119,16 @@ export interface ScopesTooltipProps { nodes: NodesMap; scopes: ScopesMap; appliedScopes: SelectedScope[]; + disabled?: boolean; + onRemoveAllClick?: () => void; } -function ScopesTooltip({ nodes, scopes, appliedScopes }: ScopesTooltipProps) { - const styles = useStyles2(getStyles); +function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disabled }: ScopesTooltipProps) { + if (appliedScopes.length === 0) { + return t('scopes.selector.input.tooltip', 'Select scope'); + } const nicePath = getScopesPath(appliedScopes, nodes); - const scopeNames = appliedScopes.map((s) => { if (s.scopeNodeId) { return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId; @@ -146,21 +137,76 @@ function ScopesTooltip({ nodes, scopes, appliedScopes }: ScopesTooltipProps) { } }); + const parentPaths = nicePath && nicePath.length > 0 ? nicePath.join(' > ') + ' > ' : ''; + return ( - <> -

- {(nicePath && nicePath.length > 0 ? nicePath.join(' > ') + ' > ' : '') + scopeNames.join(', ')} -

- + + {parentPaths + scopeNames.join(', ')} + {!disabled && ( + + Remove all + + )} + ); } const getStyles = (theme: GrafanaTheme2) => { + const baseStyles = getInputStyles({ theme }); + return { - scopePath: css({ - color: theme.colors.text.primary, - fontSize: theme.typography.pxToRem(12), - margin: theme.spacing(0, 0), + prefix: baseStyles.prefix, + suffix: baseStyles.suffix, + fakeInput: css([ + baseStyles.input, + { + width: 'auto', + minWidth: 60, + height: theme.spacing(theme.components.height.md), + maxWidth: '40%', + position: 'relative', + display: 'flex', + alignItems: 'center', + textAlign: 'left', + overflow: 'hidden', + whiteSpace: 'nowrap', + paddingRight: 28, + flexGrow: 0, + + '&:disabled': cx( + baseStyles.inputDisabled, + css({ + cursor: 'not-allowed', + }) + ), + + // We want the focus styles to appear only when tabbing through, not when clicking the button + // (and when focus is restored after command palette closes) + '&:focus': { + outline: 'unset', + boxShadow: 'unset', + }, + + '&:focus-visible': getFocusStyles(theme), + }, + ]), + text: css({ + textOverflow: 'ellipsis', + overflow: 'hidden', + }), + parentNode: css({ + marginRight: theme.spacing(1), + paddingRight: theme.spacing(1), + borderRight: `1px solid ${theme.colors.border.weak}`, + color: theme.colors.text.secondary, }), }; }; diff --git a/public/app/features/scopes/selector/ScopesSelector.tsx b/public/app/features/scopes/selector/ScopesSelector.tsx index 061829d77b7..cdb28acddf8 100644 --- a/public/app/features/scopes/selector/ScopesSelector.tsx +++ b/public/app/features/scopes/selector/ScopesSelector.tsx @@ -7,7 +7,6 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { useScopes } from '@grafana/runtime'; import { Button, Drawer, ErrorBoundary, ErrorWithStack, IconButton, Spinner, Text, useStyles2 } from '@grafana/ui'; -import { useGrafana } from 'app/core/context/GrafanaContext'; import { getModKey } from 'app/core/utils/browser'; import { useScopesServices } from '../ScopesContextProvider'; @@ -17,10 +16,7 @@ import { ScopesSelectorServiceState } from './ScopesSelectorService'; import { ScopesTree } from './ScopesTree'; export const ScopesSelector = () => { - const { chrome } = useGrafana(); - const chromeState = chrome.useState(); - const menuDockedAndOpen = !chromeState.chromeless && chromeState.megaMenuDocked && chromeState.megaMenuOpen; - const styles = useStyles2(getStyles, menuDockedAndOpen); + const styles = useStyles2(getStyles); const scopes = useScopes(); const services = useScopesServices(); @@ -81,7 +77,7 @@ export const ScopesSelector = () => { : t('scopes.dashboards.toggle.expand', 'Expand suggested dashboards list'); return ( -
+ <> { )} -
+ ); }; -const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => { +const getStyles = (theme: GrafanaTheme2) => { return { - container: css({ - display: 'flex', - flexDirection: 'row', - paddingLeft: menuDockedAndOpen ? theme.spacing(2) : 'unset', - }), dashboards: css({ color: theme.colors.text.secondary, - marginRight: theme.spacing(2), '&:hover': css({ color: theme.colors.text.primary, diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index 4c8f3b3deb0..b7083a7fc13 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -15,6 +15,7 @@ import { expandResultApplications, selectRecentScope, clearSelector, + hoverSelector, } from './utils/actions'; import { expectRecentScope, @@ -121,7 +122,8 @@ describe('Selector', () => { await selectResultApplicationsMimir(); await applyScopes(); - // recent scopes only show on top level, so we need to make sure the scopes tree is not exapnded. + // recent scopes only show on top level, so we need to make sure the scopes tree is not expanded. + await hoverSelector(); await clearSelector(); await openSelector(); diff --git a/public/app/features/scopes/tests/utils/actions.ts b/public/app/features/scopes/tests/utils/actions.ts index b018ab674be..fc5fde93193 100644 --- a/public/app/features/scopes/tests/utils/actions.ts +++ b/public/app/features/scopes/tests/utils/actions.ts @@ -47,6 +47,7 @@ const type = async (selector: () => HTMLInputElement, value: string) => { export const updateScopes = async (service: ScopesService, scopes: string[]) => act(async () => service.changeScopes(scopes)); export const openSelector = async () => click(getSelectorInput); +export const hoverSelector = async () => fireEvent.mouseOver(getSelectorInput()); export const clearSelector = async () => click(getSelectorClear); export const applyScopes = async () => { await click(getSelectorApply); diff --git a/public/app/features/scopes/tests/utils/assertions.ts b/public/app/features/scopes/tests/utils/assertions.ts index 92e98715231..f3e031de7b4 100644 --- a/public/app/features/scopes/tests/utils/assertions.ts +++ b/public/app/features/scopes/tests/utils/assertions.ts @@ -12,7 +12,7 @@ import { getResultApplicationsCloudSelect, getResultApplicationsGrafanaSelect, getResultApplicationsMimirSelect, - getSelectorInput, + getSelectorInput as getSelectorButton, getTreeHeadline, queryAllDashboard, queryDashboard, @@ -45,8 +45,8 @@ export const expectRecentScope = (scope: string) => expectInDocument(() => getRe export const expectRecentScopeNotPresentInDocument = () => expectNotInDocument(queryRecentScopesSection); export const expectRecentScopesSection = () => expectInDocument(getRecentScopesSection); export const expectScopesSelectorClosed = () => expectNotInDocument(querySelectorApply); -export const expectScopesSelectorDisabled = () => expectDisabled(getSelectorInput); -export const expectScopesSelectorValue = (value: string) => expectValue(getSelectorInput, value); +export const expectScopesSelectorDisabled = () => expectDisabled(getSelectorButton); +export const expectScopesSelectorValue = (value: string) => expect(getSelectorButton().dataset.value).toBe(value); export const expectScopesHeadline = (value: string) => expectTextContent(getTreeHeadline, value); export const expectPersistedApplicationsGrafanaNotPresent = () => expectNotInDocument(queryPersistedApplicationsGrafanaSelect); diff --git a/public/app/features/scopes/tests/utils/selectors.ts b/public/app/features/scopes/tests/utils/selectors.ts index 4e3b7ed109a..173198f7e59 100644 --- a/public/app/features/scopes/tests/utils/selectors.ts +++ b/public/app/features/scopes/tests/utils/selectors.ts @@ -35,7 +35,7 @@ const selectors = { }, }; -export const getSelectorInput = () => screen.getByTestId(selectors.selector.input); +export const getSelectorInput = () => screen.getByTestId(selectors.selector.input); export const getSelectorClear = () => screen.getByTestId(selectors.selector.clear); export const querySelectorApply = () => screen.queryByTestId(selectors.selector.apply); export const getSelectorApply = () => screen.getByTestId(selectors.selector.apply); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e2efd17b56c..305c35ceb3e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12413,8 +12413,10 @@ "cancel": "Cancel", "error-title": "An unexpected error happened", "input": { - "placeholder": "Select scopes...", - "removeAll": "Remove all scopes" + "placeholder": "No scopes", + "remove-all": "Remove all", + "removeAll": "Remove all scopes", + "tooltip": "Select scope" }, "title": "Select scopes" },