Scopes: Scope input UI update (#114002)

* Progress

* Update

* Progress

* Fixes

* Update

* update e2e

* e2e fix

* More e2e fixes

* fix
This commit is contained in:
Torkel Ödegaard
2025-11-18 10:09:55 +01:00
committed by GitHub
parent 4c75ea918a
commit c7727eb25c
14 changed files with 160 additions and 128 deletions
@@ -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);
@@ -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();
@@ -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 () => {
@@ -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
@@ -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({
</Stack>
</ToolbarButton>
)}
{topLevelScopes ? <ScopesSelector /> : undefined}
<Breadcrumbs breadcrumbs={breadcrumbs} className={styles.breadcrumbsWrapper} />
{!showToolbarLevel && breadcrumbActions}
</Stack>
@@ -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',
},
}),
};
};
@@ -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;
}
@@ -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 ? <ScopesTooltip nodes={nodes} scopes={scopes} appliedScopes={appliedScopes} /> : <></>;
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 = (
<ScopesTooltip
nodes={nodes}
scopes={scopes}
appliedScopes={appliedScopes}
onRemoveAllClick={onRemoveAllClick}
disabled={disabled}
/>
);
const parentNodePrefix = useMemo(
() =>
isLoadingTitle ? <Skeleton width={30} height={14} /> : displayTitle ? <span>{displayTitle}:</span> : 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(
() => (
<Input
readOnly
placeholder={t('scopes.selector.input.placeholder', 'Select scopes...')}
disabled={disabled}
loading={loading}
value={scopesTitles}
aria-label={t('scopes.selector.input.placeholder', 'Select scopes...')}
data-testid="scopes-selector-input"
prefix={parentNodePrefix}
suffix={
appliedScopes.length > 0 && !disabled ? (
<IconButton
aria-label={t('scopes.selector.input.removeAll', 'Remove all scopes')}
name="times"
data-testid="scopes-selector-input-clear"
onClick={() => 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 (
<Tooltip content={tooltipContent} show={appliedScopes.length === 0 ? false : tooltipVisible}>
{input}
<Tooltip content={tooltipContent} interactive>
<button
type="button"
className={styles.fakeInput}
disabled={disabled}
onClick={onClick}
aria-label={placeholderText}
data-testid="scopes-selector-input"
data-value={scopesTitles}
>
{loading && (
<div className={styles.prefix}>
<Spinner />
</div>
)}
<span className={styles.text}>
{!scopesTitles && !loading && <Text color="secondary">{placeholderText}</Text>}
{!isLoadingTitle && displayTitle && <span className={styles.parentNode}>{displayTitle}</span>}
{scopesTitles && <span>{scopesTitles}</span>}
</span>
<div className={styles.suffix}>
<Icon name="angle-down" />
</div>
</button>
</Tooltip>
);
}
@@ -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 (
<>
<p className={styles.scopePath}>
{(nicePath && nicePath.length > 0 ? nicePath.join(' > ') + ' > ' : '') + scopeNames.join(', ')}
</p>
</>
<Stack direction="column" gap={1} justifyContent="center" alignItems={'center'}>
<span>{parentPaths + scopeNames.join(', ')}</span>
{!disabled && (
<LinkButton
onClick={onRemoveAllClick}
aria-label={t('scopes.selector.input.removeAll', 'Remove all scopes')}
name="times"
data-testid="scopes-selector-input-clear"
size="sm"
fill="text"
icon="times"
>
<Trans i18nKey="scopes.selector.input.remove-all">Remove all</Trans>
</LinkButton>
)}
</Stack>
);
}
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,
}),
};
};
@@ -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 (
<div className={styles.container}>
<>
<IconButton
name="web-section-alt"
className={styles.dashboards}
@@ -160,20 +156,14 @@ export const ScopesSelector = () => {
</ErrorBoundary>
</Drawer>
)}
</div>
</>
);
};
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,
@@ -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();
@@ -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);
@@ -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);
@@ -35,7 +35,7 @@ const selectors = {
},
};
export const getSelectorInput = () => screen.getByTestId<HTMLInputElement>(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);
+4 -2
View File
@@ -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"
},