Dashboards: Per panel GroupBy action (#113445)
* wip per panel group by
* wip groupBy per panel
* wip groupBy per panel
* groupBy per panel action tests
* fix
* fix
* fix
* fix
* CR mods
* switch to dropdown
* adjust apply
* optimise action logic to avoid unnecessary triggers
* canary scenes
* wip
(cherry picked from commit 51a00db93d0805f481a9e48213382468f1eb2986)
* optimise action logic to avoid unnecessary triggers
(cherry picked from commit c4de2dfff8)
* refactor
* refactor
* memoize values/ refactor
* refactor
* refactor components - do not make async call unless queries/groupByOptions change
* canary scenes
* fix test
* Optimise handlers
* Reset options if they are not applied
* refactor subscriptions
* refactor
* scenes bump
* fixes
* properly deactivate header actions on panel edit
* list
* refactor showing menu using css, remove header deactivation code from panel-edit
* cleanup
* cleanup
* cleanup + action redesign
* i18n
* pr mods
* translations
* fix
* fix
* fix design
---------
Co-authored-by: Sergej-Vlasov <sergej.s.vlasov@gmail.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
co-authored by
Sergej-Vlasov
Dominik Prokop
parent
f0e9c2e8a3
commit
d3d294c99e
@@ -377,6 +377,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
perPanelNonApplicableDrilldowns?: boolean;
|
||||
/**
|
||||
* Enabled a group by action per panel
|
||||
*/
|
||||
panelGroupBy?: boolean;
|
||||
/**
|
||||
* Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
||||
*/
|
||||
panelFilterVariable?: boolean;
|
||||
|
||||
@@ -443,6 +443,9 @@ export const versionedComponents = {
|
||||
PanelDataErrorMessage: {
|
||||
'10.4.0': 'data-testid Panel data error message',
|
||||
},
|
||||
PanelGroupByHeaderAction: {
|
||||
'12.4.0': 'data-testid Panel group by header action',
|
||||
},
|
||||
},
|
||||
Visualization: {
|
||||
Graph: {
|
||||
|
||||
@@ -607,6 +607,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "panelGroupBy",
|
||||
Description: "Enabled a group by action per panel",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "panelFilterVariable",
|
||||
Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard",
|
||||
|
||||
Generated
+1
@@ -84,6 +84,7 @@ kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false
|
||||
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
|
||||
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
||||
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
|
||||
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
||||
|
||||
|
+13
@@ -2512,6 +2512,19 @@
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "panelGroupBy",
|
||||
"resourceVersion": "1764257043719",
|
||||
"creationTimestamp": "2025-11-27T15:24:03Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enabled a group by action per panel",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dashboards-squad",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "panelTimeSettings",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { DataQueryRequest, DataSourceApi, LoadingState } from '@grafana/data';
|
||||
import { getPanelPlugin } from '@grafana/data/test';
|
||||
import { setPluginImportUtils } from '@grafana/runtime';
|
||||
import {
|
||||
GroupByVariable,
|
||||
SceneDataTransformer,
|
||||
SceneQueryRunner,
|
||||
SceneVariableSet,
|
||||
VizPanel,
|
||||
VizPanelState,
|
||||
} from '@grafana/scenes';
|
||||
|
||||
import { activateFullSceneTree } from '../utils/test-utils';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { VizPanelHeaderActions } from './VizPanelHeaderActions';
|
||||
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
|
||||
|
||||
const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => {
|
||||
return of({
|
||||
state: LoadingState.Loading,
|
||||
series: [],
|
||||
timeRange: request.range,
|
||||
});
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => {
|
||||
return runRequestMock(ds, request);
|
||||
},
|
||||
getDataSourceSrv: () => ({
|
||||
get: jest.fn().mockResolvedValue({
|
||||
getRef: () => ({ uid: 'ds-1', type: 'test' }),
|
||||
}),
|
||||
getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds-1', type: 'test' }),
|
||||
}),
|
||||
getPluginImportUtils: () => ({
|
||||
getPanelPluginFromCache: jest.fn(() => undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
setPluginImportUtils({
|
||||
importPanelPlugin: () => Promise.resolve(getPanelPlugin({})),
|
||||
getPanelPluginFromCache: () => undefined,
|
||||
});
|
||||
|
||||
describe('VizPanelHeaderActions', () => {
|
||||
it('renders PanelGroupByAction when the group by variable applies to the panel', async () => {
|
||||
const { headerActions } = await buildScene();
|
||||
|
||||
expect(headerActions.state.supportsApplicability).toBe(true);
|
||||
});
|
||||
|
||||
it('does not set when applicability is disabled', async () => {
|
||||
const { headerActions } = await buildScene({ applicabilityEnabled: false });
|
||||
|
||||
expect(headerActions.state.supportsApplicability).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set when the datasource uid does not match', async () => {
|
||||
const { headerActions } = await buildScene({
|
||||
variableDatasourceUid: 'other-ds',
|
||||
});
|
||||
|
||||
expect(headerActions.state.supportsApplicability).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set if variable ds changes to a different type', async () => {
|
||||
const { headerActions, groupByVariable } = await buildScene();
|
||||
|
||||
expect(headerActions.state.supportsApplicability).toBe(true);
|
||||
|
||||
groupByVariable.setState({ datasource: { uid: 'ds-2' } });
|
||||
|
||||
expect(headerActions.state.supportsApplicability).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set if variable applicability becomes disabled', async () => {
|
||||
const { headerActions, groupByVariable } = await buildScene();
|
||||
|
||||
expect(headerActions.state.supportsApplicability).toBe(true);
|
||||
|
||||
groupByVariable.setState({ applicabilityEnabled: false });
|
||||
|
||||
expect(headerActions.state.supportsApplicability).toBe(false);
|
||||
});
|
||||
|
||||
it('sdoes not set if queryRunner changes datasource to different one than vars', async () => {
|
||||
const { headerActions, queryRunner } = await buildScene();
|
||||
|
||||
expect(headerActions.state.supportsApplicability).toBe(true);
|
||||
|
||||
queryRunner.setState({ datasource: { uid: 'ds-2' } });
|
||||
|
||||
expect(headerActions.state.supportsApplicability).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
interface BuildSceneOptions {
|
||||
applicabilityEnabled?: boolean;
|
||||
variableDatasourceUid?: string;
|
||||
}
|
||||
|
||||
async function buildScene(options?: BuildSceneOptions) {
|
||||
const headerActions = new VizPanelHeaderActions({});
|
||||
|
||||
const queryRunner = new SceneQueryRunner({
|
||||
datasource: { uid: 'ds-1' },
|
||||
queries: [{ refId: 'A', datasource: { uid: 'ds-1' } }],
|
||||
});
|
||||
|
||||
const groupByVariable = new GroupByVariable({
|
||||
name: 'group',
|
||||
label: 'group',
|
||||
value: [],
|
||||
text: [],
|
||||
options: [],
|
||||
applicabilityEnabled: options?.applicabilityEnabled ?? true,
|
||||
datasource: { uid: options?.variableDatasourceUid ?? 'ds-1' },
|
||||
});
|
||||
|
||||
const dataProvider = new SceneDataTransformer({
|
||||
$data: queryRunner,
|
||||
transformations: [],
|
||||
});
|
||||
|
||||
const panelState: VizPanelState = {
|
||||
key: 'panel-1',
|
||||
title: 'Panel A',
|
||||
pluginId: 'timeseries',
|
||||
headerActions,
|
||||
$data: dataProvider,
|
||||
options: {},
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
};
|
||||
|
||||
const panel = new VizPanel(panelState);
|
||||
|
||||
const scene = new DashboardScene({
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [groupByVariable],
|
||||
}),
|
||||
body: DefaultGridLayoutManager.fromVizPanels([panel]),
|
||||
});
|
||||
|
||||
activateFullSceneTree(scene);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
return { headerActions, panel, groupByVariable, queryRunner };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
|
||||
import {
|
||||
GroupByVariable,
|
||||
SceneComponentProps,
|
||||
sceneGraph,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
SceneQueryRunner,
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import { DataSourceRef } from '@grafana/schema';
|
||||
|
||||
import { verifyDrilldownApplicability } from '../utils/drilldownUtils';
|
||||
|
||||
import { PanelGroupByAction } from './panel-actions/PanelGroupByAction/PanelGroupByAction';
|
||||
|
||||
export interface VizPanelHeaderActionsState extends SceneObjectState {
|
||||
hideGroupByAction?: boolean;
|
||||
supportsApplicability?: boolean;
|
||||
}
|
||||
|
||||
export class VizPanelHeaderActions extends SceneObjectBase<VizPanelHeaderActionsState> {
|
||||
static Component = VizPanelHeaderActionsRenderer;
|
||||
|
||||
private _groupByVar?: GroupByVariable;
|
||||
private _groupBySub?: Unsubscribable;
|
||||
private _queryRunnerDatasource?: DataSourceRef;
|
||||
|
||||
constructor(state: Partial<VizPanelHeaderActionsState>) {
|
||||
super({
|
||||
hideGroupByAction: state.hideGroupByAction ?? false,
|
||||
...state,
|
||||
});
|
||||
|
||||
this.addActivationHandler(this._onActivate);
|
||||
}
|
||||
|
||||
private _onActivate = () => {
|
||||
if (!this.parent || !(this.parent instanceof VizPanel)) {
|
||||
throw new Error('VizPanelHeaderActions must be a child of a VizPanel');
|
||||
}
|
||||
|
||||
if (!this.state.hideGroupByAction) {
|
||||
this.subscribeToGroupByChanges();
|
||||
}
|
||||
|
||||
return () => {
|
||||
this._groupBySub?.unsubscribe();
|
||||
};
|
||||
};
|
||||
|
||||
private setAplicabilitySupport(groupByDs?: DataSourceRef | null, groupByApplicability?: boolean) {
|
||||
this.setState({
|
||||
supportsApplicability: verifyDrilldownApplicability(
|
||||
this,
|
||||
this._queryRunnerDatasource,
|
||||
groupByDs ?? this._groupByVar?.state.datasource ?? null,
|
||||
groupByApplicability ?? this._groupByVar?.state.applicabilityEnabled ?? false
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeToGroupByChanges() {
|
||||
const vars = sceneGraph.getVariables(this);
|
||||
const queryRunner = this.getQueryRunner();
|
||||
|
||||
this._groupByVar = vars.state.variables.find((variable) => variable instanceof GroupByVariable);
|
||||
this._queryRunnerDatasource = queryRunner?.state.datasource;
|
||||
|
||||
this.setAplicabilitySupport();
|
||||
|
||||
// check when var set updates and search for groupBy var
|
||||
this._subs.add(
|
||||
vars.subscribeToState((n) => {
|
||||
this._groupByVar = n.variables.find((variable) => variable instanceof GroupByVariable);
|
||||
|
||||
if (this._groupByVar) {
|
||||
this._groupBySub?.unsubscribe();
|
||||
this._groupBySub = this._groupByVar?.subscribeToState((n, p) => {
|
||||
if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) {
|
||||
this.setAplicabilitySupport(n.datasource, n.applicabilityEnabled);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// update query runner datasource changes
|
||||
this._subs.add(
|
||||
queryRunner?.subscribeToState((n, p) => {
|
||||
if (n.datasource !== p.datasource) {
|
||||
this._queryRunnerDatasource = n.datasource;
|
||||
|
||||
this.setAplicabilitySupport();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this._groupBySub = this._groupByVar?.subscribeToState((n, p) => {
|
||||
if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) {
|
||||
this.setAplicabilitySupport(n.datasource, n.applicabilityEnabled);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getQueryRunner() {
|
||||
const panel = this.parent;
|
||||
const dataObject = panel ? sceneGraph.getData(panel) : undefined;
|
||||
const queryRunner = dataObject?.state.$data;
|
||||
|
||||
if (!queryRunner || !(queryRunner instanceof SceneQueryRunner)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return queryRunner;
|
||||
}
|
||||
}
|
||||
|
||||
export function VizPanelHeaderActionsRenderer({ model }: SceneComponentProps<VizPanelHeaderActions>) {
|
||||
const { hideGroupByAction, supportsApplicability } = model.useState();
|
||||
const variables = sceneGraph.getVariables(model);
|
||||
const groupByVariable = variables.state.variables.find((variable) => variable instanceof GroupByVariable);
|
||||
const queryRunner = model.getQueryRunner();
|
||||
const queries = queryRunner?.state.data?.request?.targets ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideGroupByAction && supportsApplicability && (
|
||||
<div className="show-on-hover">
|
||||
<PanelGroupByAction groupByVariable={groupByVariable!} queries={queries} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export class VizPanelSubHeader extends SceneObjectBase<VizPanelSubHeaderState> {
|
||||
private refreshDrilldownVarsSubscriptions() {
|
||||
if (this._groupByVar) {
|
||||
this._groupBySub?.unsubscribe();
|
||||
this._groupByVar?.subscribeToState((n, p) => {
|
||||
this._groupBySub = this._groupByVar?.subscribeToState((n, p) => {
|
||||
if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) {
|
||||
this.setDrilldownApplicabilitySupportHelper(undefined, {
|
||||
datasource: n.datasource,
|
||||
@@ -127,7 +127,8 @@ export class VizPanelSubHeader extends SceneObjectBase<VizPanelSubHeaderState> {
|
||||
}
|
||||
|
||||
if (this._adHocVar) {
|
||||
this._adHocVar?.subscribeToState((n, p) => {
|
||||
this._adHocSub?.unsubscribe();
|
||||
this._adHocSub = this._adHocVar?.subscribeToState((n, p) => {
|
||||
if (n.datasource !== p.datasource || n.applicabilityEnabled !== p.applicabilityEnabled) {
|
||||
this.setDrilldownApplicabilitySupportHelper({
|
||||
datasource: n.datasource,
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { fuzzySearch } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { GroupByVariable, SceneDataQuery, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
|
||||
import { Button, Icon, Popover } from '@grafana/ui';
|
||||
|
||||
import { PanelGroupByActionPopover } from './PanelGroupByActionPopover';
|
||||
|
||||
interface Props {
|
||||
groupByVariable: GroupByVariable;
|
||||
queries: SceneDataQuery[];
|
||||
}
|
||||
|
||||
export function PanelGroupByAction({ groupByVariable, queries }: Props) {
|
||||
const { options: groupByOptions } = groupByVariable.useState();
|
||||
|
||||
const [options, setOptions] = useState<VariableValueOption[]>([]);
|
||||
const [selectedValues, setSelectedValues] = useState<VariableValueSingle[]>([]);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [reloadOptions, setReloadOptions] = useState(false);
|
||||
const [isPopoverVisible, setPopoverVisible] = useState<boolean>(false);
|
||||
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const fetchOptions = useCallback(async () => {
|
||||
if (!groupByVariable || !reloadOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (groupByOptions.length === 0) {
|
||||
await lastValueFrom(groupByVariable.validateAndUpdate());
|
||||
const options = await getApplicableGroupByOptions(groupByVariable, groupByVariable.state.options, queries);
|
||||
|
||||
setSelectedValues(getGroupByValue(groupByVariable));
|
||||
setOptions(options);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = await getApplicableGroupByOptions(groupByVariable, groupByOptions, queries);
|
||||
|
||||
setSelectedValues(getGroupByValue(groupByVariable));
|
||||
setOptions(options);
|
||||
} catch (error) {
|
||||
setSelectedValues([]);
|
||||
setOptions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setReloadOptions(false);
|
||||
}
|
||||
}, [groupByOptions, groupByVariable, queries, reloadOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setReloadOptions(true);
|
||||
}, [groupByOptions, queries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPopoverVisible) {
|
||||
fetchOptions();
|
||||
}
|
||||
}, [fetchOptions, isPopoverVisible]);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchValue) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const haystack = options.map((option) => option.label);
|
||||
const indices = fuzzySearch(haystack, searchValue);
|
||||
return indices.map((idx) => options[idx]);
|
||||
}, [options, searchValue]);
|
||||
|
||||
const onCancel = () => {
|
||||
setSearchValue('');
|
||||
setPopoverVisible(false);
|
||||
};
|
||||
|
||||
const openPopover = () => {
|
||||
// Reset checked state to match current variable value when opening
|
||||
setSelectedValues(getGroupByValue(groupByVariable));
|
||||
setPopoverVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
fill="text"
|
||||
data-testid={selectors.components.Panels.Panel.PanelGroupByHeaderAction}
|
||||
ref={ref}
|
||||
onClick={(ev) => {
|
||||
openPopover();
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="panel-group-by.button">Group by</Trans>
|
||||
<Icon name="angle-down" />
|
||||
{isPopoverVisible && ref.current && (
|
||||
<Popover
|
||||
content={
|
||||
<PanelGroupByActionPopover
|
||||
groupByVariable={groupByVariable}
|
||||
onCancel={onCancel}
|
||||
isLoading={isLoading}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
options={filteredOptions}
|
||||
values={selectedValues}
|
||||
onValuesChange={setSelectedValues}
|
||||
/>
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
placement="bottom-start"
|
||||
referenceElement={ref.current}
|
||||
show
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function getGroupByValue(groupByVariable: GroupByVariable) {
|
||||
return Array.isArray(groupByVariable.state.value)
|
||||
? groupByVariable.state.value
|
||||
: groupByVariable.state.value
|
||||
? [groupByVariable.state.value]
|
||||
: [];
|
||||
}
|
||||
|
||||
async function getApplicableGroupByOptions(
|
||||
groupByVariable: GroupByVariable,
|
||||
options: VariableValueOption[],
|
||||
queries: SceneDataQuery[]
|
||||
) {
|
||||
const values = options.map((option) => option.value);
|
||||
const applicability = await groupByVariable.getGroupByApplicabilityForQueries(values, queries);
|
||||
|
||||
return applicability
|
||||
? applicability.filter((item) => item.applicable).map((item) => ({ label: item.key, value: item.key }))
|
||||
: options;
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { GroupByVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
|
||||
import { Button, Checkbox, ClickOutsideWrapper, Icon, Input, Stack, useStyles2 } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
groupByVariable: GroupByVariable;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
searchValue: string;
|
||||
setSearchValue: (value: string) => void;
|
||||
options: VariableValueOption[];
|
||||
values: VariableValueSingle[];
|
||||
onValuesChange: (value: VariableValueSingle[]) => void;
|
||||
}
|
||||
|
||||
export function PanelGroupByActionPopover({
|
||||
groupByVariable,
|
||||
onCancel,
|
||||
isLoading,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
options,
|
||||
values,
|
||||
onValuesChange,
|
||||
}: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const onCheckedChanged = useCallback(
|
||||
(option: VariableValueOption) => (event: React.FormEvent<HTMLInputElement>) => {
|
||||
const newValues = event.currentTarget.checked
|
||||
? values.concat(option.value)
|
||||
: values.filter((c) => c !== option.value);
|
||||
|
||||
onValuesChange(newValues);
|
||||
},
|
||||
[onValuesChange, values]
|
||||
);
|
||||
|
||||
const isChecked = (option: VariableValueOption) => {
|
||||
return values.includes(option.value);
|
||||
};
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
if (!values.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
groupByVariable.changeValueTo(values, values.map(String), true);
|
||||
onCancel();
|
||||
}, [groupByVariable, onCancel, values]);
|
||||
|
||||
const isAnyOptionChecked = () => {
|
||||
if (!values.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return values.some((value) => options.find((option) => option.value === value));
|
||||
};
|
||||
|
||||
return (
|
||||
<ClickOutsideWrapper onClick={onCancel} useCapture={true}>
|
||||
{/* This is just blocking click events from bubbeling and should not have a keyboard interaction. */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
|
||||
<div className={styles.menuContainer} onClick={(ev) => ev.stopPropagation()}>
|
||||
<Stack direction="column">
|
||||
<div className={styles.searchContainer}>
|
||||
<Input
|
||||
prefix={<Icon name="search" />}
|
||||
placeholder={t('panel-group-by.search-placeholder', 'Search')}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.listContainer}>
|
||||
{isLoading ? (
|
||||
<div className={styles.emptyMessage}>
|
||||
<Trans i18nKey="panel-group-by.loading">Loading options</Trans>
|
||||
</div>
|
||||
) : options.length === 0 ? (
|
||||
<div className={styles.emptyMessage}>
|
||||
<Trans i18nKey="panel-group-by.no-options">No options found</Trans>
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
return (
|
||||
<div key={String(option.value)} className={cx(styles.option)}>
|
||||
<Checkbox value={isChecked(option)} label={option.label} onChange={onCheckedChanged(option)} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Stack justifyContent="end" direction="row-reverse">
|
||||
<Button size="sm" onClick={handleApply} disabled={!isAnyOptionChecked()}>
|
||||
<Trans i18nKey="grafana-ui.table.filter-popup-apply">Ok</Trans>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onCancel}>
|
||||
<Trans i18nKey="grafana-ui.table.filter-popup-cancel">Cancel</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</ClickOutsideWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
menuContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: theme.colors.background.elevated,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
boxShadow: theme.shadows.z3,
|
||||
padding: theme.spacing(2),
|
||||
}),
|
||||
searchContainer: css({
|
||||
width: '100%',
|
||||
paddingBottom: theme.spacing(1),
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
listContainer: css({
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
minHeight: '100px',
|
||||
maxHeight: '300px',
|
||||
padding: theme.spacing(0.5),
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
option: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(1),
|
||||
cursor: 'pointer',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
'&:hover': {
|
||||
background: theme.colors.background.secondary,
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.colors.primary.border}`,
|
||||
outlineOffset: '-2px',
|
||||
},
|
||||
}),
|
||||
emptyMessage: css({
|
||||
padding: theme.spacing(2),
|
||||
textAlign: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior';
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks';
|
||||
import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior';
|
||||
import { PanelNotices } from '../../scene/PanelNotices';
|
||||
import { VizPanelHeaderActions } from '../../scene/VizPanelHeaderActions';
|
||||
import { VizPanelSubHeader } from '../../scene/VizPanelSubHeader';
|
||||
import { AutoGridItem } from '../../scene/layout-auto-grid/AutoGridItem';
|
||||
import { DashboardGridItem } from '../../scene/layout-default/DashboardGridItem';
|
||||
@@ -71,6 +72,9 @@ export function buildVizPanel(panel: PanelKind, id?: number): VizPanel {
|
||||
seriesLimit: config.panelSeriesLimit,
|
||||
$data: createPanelDataProvider(panel),
|
||||
titleItems,
|
||||
headerActions: new VizPanelHeaderActions({
|
||||
hideGroupByAction: !config.featureToggles.panelGroupBy,
|
||||
}),
|
||||
subHeader: new VizPanelSubHeader({
|
||||
hideNonApplicableDrilldowns: !config.featureToggles.perPanelNonApplicableDrilldowns,
|
||||
}),
|
||||
@@ -121,6 +125,9 @@ export function buildLibraryPanel(panel: LibraryPanelKind, id?: number): VizPane
|
||||
}),
|
||||
],
|
||||
extendPanelContext: setDashboardPanelContext,
|
||||
headerActions: new VizPanelHeaderActions({
|
||||
hideGroupByAction: !config.featureToggles.panelGroupBy,
|
||||
}),
|
||||
pluginId: LibraryPanelBehavior.LOADING_VIZ_PANEL_PLUGIN_ID,
|
||||
title: panel.spec.title,
|
||||
options: {},
|
||||
|
||||
@@ -46,6 +46,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
|
||||
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
|
||||
import { PanelNotices } from '../scene/PanelNotices';
|
||||
import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions';
|
||||
import { VizPanelSubHeader } from '../scene/VizPanelSubHeader';
|
||||
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||
@@ -442,6 +443,9 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem {
|
||||
hoverHeaderOffset: 0,
|
||||
$data: createPanelDataProvider(panel),
|
||||
titleItems,
|
||||
headerActions: new VizPanelHeaderActions({
|
||||
hideGroupByAction: !config.featureToggles.panelGroupBy,
|
||||
}),
|
||||
subHeader: new VizPanelSubHeader({
|
||||
hideNonApplicableDrilldowns: !config.featureToggles.perPanelNonApplicableDrilldowns,
|
||||
}),
|
||||
|
||||
@@ -24,6 +24,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
|
||||
import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
|
||||
import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel';
|
||||
import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions';
|
||||
import { VizPanelSubHeader } from '../scene/VizPanelSubHeader';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
|
||||
@@ -283,6 +284,9 @@ export function getDefaultVizPanel(): VizPanel {
|
||||
menu: new VizPanelMenu({
|
||||
$behaviors: [panelMenuBehavior],
|
||||
}),
|
||||
headerActions: new VizPanelHeaderActions({
|
||||
hideGroupByAction: !config.featureToggles.panelGroupBy,
|
||||
}),
|
||||
$data: new SceneDataTransformer({
|
||||
$data: new SceneQueryRunner({
|
||||
queries: [{ refId: 'A' }],
|
||||
|
||||
@@ -11094,6 +11094,12 @@
|
||||
"could-anything-matching-query": "Could not find anything matching your query"
|
||||
}
|
||||
},
|
||||
"panel-group-by": {
|
||||
"button": "Group by",
|
||||
"loading": "Loading options",
|
||||
"no-options": "No options found",
|
||||
"search-placeholder": "Search"
|
||||
},
|
||||
"panel-type-filter": {
|
||||
"clear-button": "Clear types",
|
||||
"select-aria-label": "Panel type filter",
|
||||
|
||||
Reference in New Issue
Block a user