Dashboards: AdHoc and GroupBy wrapper (#115124)

* wip; DrilldownControls

* use wrapper so that drilldown controls wrap inline

* keep labels on top when input expands vertically

* add clear all button

* add collapsible prop

* i18n

* Increase maxWidth for adhoc

* bump scenes for testing

* fix

* remove clear all button

* use new feature toggle; pass collapsible in v2

* update variable controls to use new feature flag

* cleanup

* wip (#115441)

* wip

* fix

* update wrapping on smaller screens

---------

Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>

* Filter out variables that are not in inControlsMenu

* filter out inControlsMenu vars, not hidden ones

* canary scenes

* fix

* cleanup

* canary scenes

* pass wideInput to groupby based on ff

* update var name and bump scenes

* bump scenes

* yarn lock

---------

Co-authored-by: Victor Marin <victor.marin@grafana.com>
This commit is contained in:
Haris Rozajac
2025-12-18 11:58:21 -07:00
committed by GitHub
parent 1850163346
commit 05fd304dbd
12 changed files with 240 additions and 16 deletions
+2 -2
View File
@@ -295,8 +295,8 @@
"@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^6.51.0",
"@grafana/scenes-react": "^6.51.0",
"@grafana/scenes": "6.52.0",
"@grafana/scenes-react": "6.52.0",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
+4
View File
@@ -499,6 +499,10 @@ export interface FeatureToggles {
*/
newDashboardWithFiltersAndGroupBy?: boolean;
/**
* Wraps the ad hoc and group by variables in a single wrapper, with all other variables below it
*/
dashboardAdHocAndGroupByWrapper?: boolean;
/**
* Updates CloudWatch label parsing to be more accurate
* @default true
*/
+7
View File
@@ -820,6 +820,13 @@ var (
Owner: grafanaDashboardsSquad,
HideFromDocs: true,
},
{
Name: "dashboardAdHocAndGroupByWrapper",
Description: "Wraps the ad hoc and group by variables in a single wrapper, with all other variables below it",
Stage: FeatureStageExperimental,
Owner: grafanaDashboardsSquad,
HideFromDocs: true,
},
{
Name: "cloudWatchNewLabelParsing",
Description: "Updates CloudWatch label parsing to be more accurate",
+1
View File
@@ -113,6 +113,7 @@ scopeFilters,experimental,@grafana/dashboards-squad,false,false,false
oauthRequireSubClaim,experimental,@grafana/identity-access-team,false,false,false
refreshTokenRequired,experimental,@grafana/identity-access-team,false,false,false
newDashboardWithFiltersAndGroupBy,experimental,@grafana/dashboards-squad,false,false,false
dashboardAdHocAndGroupByWrapper,experimental,@grafana/dashboards-squad,false,false,false
cloudWatchNewLabelParsing,GA,@grafana/aws-datasources,false,false,false
disableNumericMetricsSortingInExpressions,experimental,@grafana/oss-big-tent,false,true,false
grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
113 oauthRequireSubClaim experimental @grafana/identity-access-team false false false
114 refreshTokenRequired experimental @grafana/identity-access-team false false false
115 newDashboardWithFiltersAndGroupBy experimental @grafana/dashboards-squad false false false
116 dashboardAdHocAndGroupByWrapper experimental @grafana/dashboards-squad false false false
117 cloudWatchNewLabelParsing GA @grafana/aws-datasources false false false
118 disableNumericMetricsSortingInExpressions experimental @grafana/oss-big-tent false true false
119 grafanaManagedRecordingRules experimental @grafana/alerting-squad false false false
+4
View File
@@ -339,6 +339,10 @@ const (
// Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering.
FlagNewDashboardWithFiltersAndGroupBy = "newDashboardWithFiltersAndGroupBy"
// FlagDashboardAdHocAndGroupByWrapper
// Wraps the ad hoc and group by variables in a single wrapper, with all other variables below it
FlagDashboardAdHocAndGroupByWrapper = "dashboardAdHocAndGroupByWrapper"
// FlagCloudWatchNewLabelParsing
// Updates CloudWatch label parsing to be more accurate
FlagCloudWatchNewLabelParsing = "cloudWatchNewLabelParsing"
+13
View File
@@ -922,6 +922,19 @@
"frontend": true
}
},
{
"metadata": {
"name": "dashboardAdHocAndGroupByWrapper",
"resourceVersion": "1765841806645",
"creationTimestamp": "2025-12-15T23:36:46Z"
},
"spec": {
"description": "Wraps the ad hoc and group by variables in a single wrapper, with all other variables below it",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad",
"hideFromDocs": true
}
},
{
"metadata": {
"name": "dashboardDisableSchemaValidationV1",
@@ -16,6 +16,7 @@ import {
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
CancelActivationHandler,
sceneUtils,
} from '@grafana/scenes';
import { Box, Button, useStyles2 } from '@grafana/ui';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
@@ -27,6 +28,7 @@ import { getDashboardSceneFor } from '../utils/utils';
import { DashboardDataLayerControls } from './DashboardDataLayerControls';
import { DashboardLinksControls } from './DashboardLinksControls';
import { DashboardScene } from './DashboardScene';
import { DrilldownControls } from './DrilldownControls';
import { VariableControls } from './VariableControls';
import { DashboardControlsButton } from './dashboard-controls-menu/DashboardControlsMenuButton';
import { hasDashboardControls, useHasDashboardControls } from './dashboard-controls-menu/utils';
@@ -151,11 +153,63 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
const showDebugger = window.location.search.includes('scene-debugger');
const hasDashboardControls = useHasDashboardControls(dashboard);
// Get adhoc and groupby variables for drilldown controls
const { variables } = sceneGraph.getVariables(dashboard)?.useState() ?? { variables: [] };
const visibleVariables = variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu);
const adHocVar = visibleVariables.find((v) => sceneUtils.isAdHocVariable(v));
const groupByVar = visibleVariables.find((v) => sceneUtils.isGroupByVariable(v));
const useUnifiedDrilldownUI = config.featureToggles.dashboardAdHocAndGroupByWrapper && adHocVar && groupByVar;
if (!model.hasControls()) {
// To still have spacing when no controls are rendered
return <Box padding={1}>{renderHiddenVariables(dashboard)}</Box>;
}
// When dashboardAdHocAndGroupByWrapper is enabled, use the new layout with topRow
if (useUnifiedDrilldownUI) {
return (
<div
data-testid={selectors.pages.Dashboard.Controls}
className={cx(styles.controls, editPanel && styles.controlsPanelEdit)}
>
<div className={styles.topRow}>
{config.featureToggles.scopeFilters && !editPanel && (
<ContextualNavigationPaneToggle className={styles.contextualNavToggleNewLayout} hideWhenOpen={true} />
)}
{!hideVariableControls && (
<div className={styles.drilldownControlsContainer}>
<DrilldownControls adHocVar={adHocVar} groupByVar={groupByVar} />
</div>
)}
<div className={cx(styles.rightControlsNewLayout, editPanel && styles.rightControlsWrap)}>
{!hideTimeControls && (
<div className={styles.fixedControlsNewLayout}>
<timePicker.Component model={timePicker} />
<refreshPicker.Component model={refreshPicker} />
</div>
)}
{config.featureToggles.dashboardNewLayouts && (
<div className={styles.fixedControlsNewLayout}>
<DashboardControlActions dashboard={dashboard} />
</div>
)}
</div>
</div>
{!hideVariableControls && (
<>
<VariableControls dashboard={dashboard} />
<DashboardDataLayerControls dashboard={dashboard} />
</>
)}
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} dashboard={dashboard} />}
{!hideDashboardControls && hasDashboardControls && <DashboardControlsButton dashboard={dashboard} />}
{editPanel && <PanelEditControls panelEditor={editPanel} />}
{showDebugger && <SceneDebugger scene={model} key={'scene-debugger'} />}
</div>
);
}
// Original layout when feature toggle is off
return (
<div
data-testid={selectors.pages.Dashboard.Controls}
@@ -243,6 +297,7 @@ function renderHiddenVariables(dashboard: DashboardScene) {
function getStyles(theme: GrafanaTheme2) {
return {
// Original controls style
controls: css({
gap: theme.spacing(1),
padding: theme.spacing(2, 2, 1, 2),
@@ -266,10 +321,31 @@ function getStyles(theme: GrafanaTheme2) {
// In panel edit we do not need any right padding as the splitter is providing it
paddingRight: 0,
}),
// New layout styles (used when feature toggle is on)
topRow: css({
display: 'flex',
alignItems: 'flex-start',
gap: theme.spacing(1),
width: '100%',
marginBottom: theme.spacing(1),
[theme.breakpoints.down('sm')]: {
flexWrap: 'wrap',
},
}),
drilldownControlsContainer: css({
flex: 1,
minWidth: 0,
display: 'flex',
[theme.breakpoints.down('sm')]: {
order: 1, // Move below the time controls
flex: '1 1 100%', // Take full width to force new line
},
}),
embedded: css({
background: 'unset',
position: 'unset',
}),
// Original rightControls style
rightControls: css({
display: 'flex',
gap: theme.spacing(1),
@@ -279,6 +355,15 @@ function getStyles(theme: GrafanaTheme2) {
maxWidth: '100%',
minWidth: 0,
}),
// Modified rightControls for new layout
rightControlsNewLayout: css({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'flex-start',
flexWrap: 'wrap',
flexShrink: 0,
}),
// Original fixedControls style
fixedControls: css({
display: 'flex',
justifyContent: 'flex-end',
@@ -289,6 +374,14 @@ function getStyles(theme: GrafanaTheme2) {
flexShrink: 0,
alignSelf: 'flex-start',
}),
// Fixed controls for new layout (no margin/order)
fixedControlsNewLayout: css({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(1),
flexShrink: 0,
alignSelf: 'flex-start',
}),
dashboardControlsButton: css({
order: 2,
marginLeft: 'auto',
@@ -301,5 +394,9 @@ function getStyles(theme: GrafanaTheme2) {
display: 'inline-flex',
margin: theme.spacing(0, 1, 1, 0),
}),
contextualNavToggleNewLayout: css({
display: 'inline-flex',
flexShrink: 0,
}),
};
}
@@ -0,0 +1,77 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { AdHocFiltersVariable, GroupByVariable } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { VariableValueSelectWrapper } from './VariableControls';
interface DrilldownControlsProps {
adHocVar: AdHocFiltersVariable;
groupByVar: GroupByVariable;
}
/**
* DrilldownControls renders the AdHoc and GroupBy variables in a single row above the other variables.
*/
export function DrilldownControls({ adHocVar, groupByVar }: DrilldownControlsProps) {
const styles = useStyles2(getStyles);
return (
<div className={styles.drilldownRow}>
<div className={styles.adHocContainer}>
<VariableValueSelectWrapper variable={adHocVar} />
</div>
<div className={styles.groupByContainer}>
<VariableValueSelectWrapper variable={groupByVar} />
</div>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
drilldownRow: css({
display: 'flex',
flexWrap: 'nowrap',
[theme.breakpoints.down('xl')]: {
flexWrap: 'wrap',
},
gap: theme.spacing(1),
width: '100%',
}),
adHocContainer: css({
flex: '7 1 0%', // 70% of available space
minWidth: 0,
[theme.breakpoints.down('xl')]: {
// Force full width, causing groupBy to wrap
flex: '1 1 100%',
},
display: 'flex',
// Make the wrapper and its children take full width
'& > div': {
alignItems: 'flex-start',
width: '100%',
flex: 1,
},
}),
groupByContainer: css({
flex: '3 1 0%', // 30% of available space
minWidth: 0,
display: 'flex',
// Make the wrapper and its children take full width
'& > div': {
alignItems: 'flex-start',
width: '100%',
flex: 1,
},
[theme.breakpoints.down('sm')]: {
minWidth: '200px',
},
}),
clearAllButton: css({
alignSelf: 'flex-start',
fontSize: theme.typography.bodySmall.fontSize,
padding: theme.spacing(0.5, 0.5),
marginBottom: theme.spacing(1),
}),
});
@@ -20,13 +20,30 @@ import { AddVariableButton } from './VariableControlsAddButton';
export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
const { variables } = sceneGraph.getVariables(dashboard)!.useState();
// Get visible variables for drilldown layout
const visibleVariables = variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu);
const adHocVar = visibleVariables.find((v) => sceneUtils.isAdHocVariable(v));
const groupByVar = visibleVariables.find((v) => sceneUtils.isGroupByVariable(v));
const hasDrilldownControls = config.featureToggles.dashboardAdHocAndGroupByWrapper && adHocVar && groupByVar;
const restVariables = visibleVariables.filter(
(v) => v.state.name !== adHocVar?.state.name && v.state.name !== groupByVar?.state.name
);
// Variables to render (exclude adhoc/groupby when drilldown controls are shown in top row)
const variablesToRender = hasDrilldownControls
? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu)
: variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu);
return (
<>
{variables
.filter((v) => v.state.hide !== VariableHide.inControlsMenu)
.map((variable) => (
{variablesToRender.length > 0 &&
variablesToRender.map((variable) => (
<VariableValueSelectWrapper key={variable.state.key} variable={variable} />
))}
{config.featureToggles.dashboardNewLayouts ? <AddVariableButton dashboard={dashboard} /> : null}
</>
);
@@ -336,6 +336,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
supportsMultiValueOperators: Boolean(
getDataSourceSrv().getInstanceSettings({ type: ds?.type })?.meta.multiValueFilterOperators
),
collapsible: config.featureToggles.dashboardAdHocAndGroupByWrapper,
};
if (variable.spec.allowCustomValue !== undefined) {
adhocVariableState.allowCustomValue = variable.spec.allowCustomValue;
@@ -460,6 +461,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
skipUrlSync: variable.spec.skipUrlSync,
isMulti: variable.spec.multi,
hide: transformVariableHideToEnumV1(variable.spec.hide),
wideInput: config.featureToggles.dashboardAdHocAndGroupByWrapper,
drilldownRecommendationsEnabled: config.featureToggles.drilldownRecommendations,
// @ts-expect-error
defaultOptions: variable.options,
@@ -164,6 +164,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
useQueriesAsFilterForOptions: true,
drilldownRecommendationsEnabled: config.featureToggles.drilldownRecommendations,
layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined,
collapsible: config.featureToggles.dashboardAdHocAndGroupByWrapper,
supportsMultiValueOperators: Boolean(
getDataSourceSrv().getInstanceSettings({ type: variable.datasource?.type })?.meta.multiValueFilterOperators
),
@@ -288,6 +289,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
text: variable.current?.text || [],
skipUrlSync: variable.skipUrlSync,
hide: variable.hide,
wideInput: config.featureToggles.dashboardAdHocAndGroupByWrapper,
// @ts-expect-error
defaultOptions: variable.options,
defaultValue: variable.defaultValue,
+11 -11
View File
@@ -3604,11 +3604,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:^6.51.0":
version: 6.51.0
resolution: "@grafana/scenes-react@npm:6.51.0"
"@grafana/scenes-react@npm:6.52.0":
version: 6.52.0
resolution: "@grafana/scenes-react@npm:6.52.0"
dependencies:
"@grafana/scenes": "npm:6.51.0"
"@grafana/scenes": "npm:6.52.0"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3620,7 +3620,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/14acdfe5220e67e7450780320b779e2e4a255995d55f0c82eb0d25933e72598e54826df0a8beee05591efe01a91ddab840483fea3bb828bd5925c3f0b44b8d17
checksum: 10/7f121bcc4fd50f525c7c3457666ad3a32b04783d322d6715aedb6119538f911d0ec265c9c5b49a80478c1deb99286d36d003399a8831f76b6c483f4458b4ce8b
languageName: node
linkType: hard
@@ -3650,9 +3650,9 @@ __metadata:
languageName: node
linkType: hard
"@grafana/scenes@npm:6.51.0, @grafana/scenes@npm:^6.51.0":
version: 6.51.0
resolution: "@grafana/scenes@npm:6.51.0"
"@grafana/scenes@npm:6.52.0":
version: 6.52.0
resolution: "@grafana/scenes@npm:6.52.0"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3672,7 +3672,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/4e4f43babe786ff729d58b7636182df57c58ce40c13b56036f725c070e0cf597cbe52aaa0f811184b8d42d8d1f9a32679695471d410f883051b09da44f8bf36a
checksum: 10/e52e0fb83396776c6cb79f8ac6a8aad0799eb2ccce9d0139f5734a49c3add7a1e3b97f14e0142c95b2bceee3ed8fa97b675b9b94c02382ecd683f470d06ef145
languageName: node
linkType: hard
@@ -19508,8 +19508,8 @@ __metadata:
"@grafana/plugin-ui": "npm:^0.11.1"
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:^6.51.0"
"@grafana/scenes-react": "npm:^6.51.0"
"@grafana/scenes": "npm:6.52.0"
"@grafana/scenes-react": "npm:6.52.0"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/test-utils": "workspace:*"