Dashboards: Per panel filtering for timeseries (#114499)
* 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
* wip
* wip
* wip
* wip
* wip
* tests
* pr mods
* translations
* fix
* fix
* fixes
* translations
* translations
* extra ff check
* CR mods
---------
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
fef6196195
commit
7ea009c7f8
+5
-1
@@ -377,10 +377,14 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
perPanelNonApplicableDrilldowns?: boolean;
|
perPanelNonApplicableDrilldowns?: boolean;
|
||||||
/**
|
/**
|
||||||
* Enabled a group by action per panel
|
* Enables a group by action per panel
|
||||||
*/
|
*/
|
||||||
panelGroupBy?: boolean;
|
panelGroupBy?: boolean;
|
||||||
/**
|
/**
|
||||||
|
* Enables filtering by grouping labels on the panel level through legend or tooltip
|
||||||
|
*/
|
||||||
|
perPanelFiltering?: boolean;
|
||||||
|
/**
|
||||||
* Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
* Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
||||||
*/
|
*/
|
||||||
panelFilterVariable?: boolean;
|
panelFilterVariable?: boolean;
|
||||||
|
|||||||
@@ -1490,6 +1490,16 @@ export const versionedComponents = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
VizTooltipFooter: {
|
||||||
|
buttons: {
|
||||||
|
apply: {
|
||||||
|
['12.1.0']: 'data-testid viz-tooltip-footer-apply-filters-button',
|
||||||
|
},
|
||||||
|
applyInverse: {
|
||||||
|
['12.1.0']: 'data-testid viz-tooltip-footer-apply-inverse-filters-button',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
} satisfies VersionedSelectorGroup;
|
} satisfies VersionedSelectorGroup;
|
||||||
|
|
||||||
export type VersionedComponents = typeof versionedComponents;
|
export type VersionedComponents = typeof versionedComponents;
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ export interface PanelContext {
|
|||||||
*/
|
*/
|
||||||
onAddAdHocFilter?: (item: AdHocFilterItem) => void;
|
onAddAdHocFilter?: (item: AdHocFilterItem) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns filters based on existing grouping or an empty array
|
||||||
|
*/
|
||||||
|
getFiltersBasedOnGrouping?: (items: AdHocFilterItem[]) => AdHocFilterItem[];
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Used to apply multiple filters at once
|
||||||
|
*/
|
||||||
|
onAddAdHocFilters?: (items: AdHocFilterItem[]) => void;
|
||||||
/**
|
/**
|
||||||
* Enables modifying thresholds directly from the panel
|
* Enables modifying thresholds directly from the panel
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
import { Field, FieldType, LinkModel } from '@grafana/data';
|
import { Field, FieldType, LinkModel } from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { VizTooltipFooter, AdHocFilterModel } from './VizTooltipFooter';
|
import { VizTooltipFooter, AdHocFilterModel } from './VizTooltipFooter';
|
||||||
|
|
||||||
@@ -89,4 +90,65 @@ describe('VizTooltipFooter', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('button', { name: /filter for 'testValue'/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /filter for 'testValue'/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render filter by grouping buttons and fire onclick', async () => {
|
||||||
|
const onForClick = jest.fn();
|
||||||
|
const onOutClick = jest.fn();
|
||||||
|
|
||||||
|
const filterByGroupedLabels = {
|
||||||
|
onFilterForGroupedLabels: onForClick,
|
||||||
|
onFilterOutGroupedLabels: onOutClick,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<VizTooltipFooter dataLinks={[]} filterByGroupedLabels={filterByGroupedLabels} />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
const onForButton = screen.getByRole('button', { name: /Apply as filter/i });
|
||||||
|
expect(onForButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
const onOutButton = screen.getByRole('button', { name: /Apply as inverse filter/i });
|
||||||
|
expect(onOutButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(onForButton);
|
||||||
|
expect(onForClick).toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userEvent.click(onOutButton);
|
||||||
|
expect(onOutClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render filter by grouping buttons when there are one-click links', () => {
|
||||||
|
const filterByGroupedLabels = {
|
||||||
|
onFilterForGroupedLabels: jest.fn(),
|
||||||
|
onFilterOutGroupedLabels: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = jest.fn();
|
||||||
|
const field: Field = {
|
||||||
|
name: '',
|
||||||
|
type: FieldType.string,
|
||||||
|
values: [],
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const oneClickLink: LinkModel<Field> = {
|
||||||
|
href: '#',
|
||||||
|
onClick,
|
||||||
|
title: 'One Click Link',
|
||||||
|
origin: field,
|
||||||
|
target: undefined,
|
||||||
|
oneClick: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<VizTooltipFooter dataLinks={[oneClickLink]} filterByGroupedLabels={filterByGroupedLabels} />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId(selectors.components.VizTooltipFooter.buttons.apply)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId(selectors.components.VizTooltipFooter.buttons.applyInverse)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data';
|
import { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans } from '@grafana/i18n';
|
import { Trans } from '@grafana/i18n';
|
||||||
|
|
||||||
import { useStyles2 } from '../../themes/ThemeContext';
|
import { useStyles2 } from '../../themes/ThemeContext';
|
||||||
@@ -17,10 +18,16 @@ export interface AdHocFilterModel extends AdHocFilterItem {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FilterByGroupedLabelsModel {
|
||||||
|
onFilterForGroupedLabels?: () => void;
|
||||||
|
onFilterOutGroupedLabels?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface VizTooltipFooterProps {
|
interface VizTooltipFooterProps {
|
||||||
dataLinks: Array<LinkModel<Field>>;
|
dataLinks: Array<LinkModel<Field>>;
|
||||||
actions?: Array<ActionModel<Field>>;
|
actions?: Array<ActionModel<Field>>;
|
||||||
adHocFilters?: AdHocFilterModel[];
|
adHocFilters?: AdHocFilterModel[];
|
||||||
|
filterByGroupedLabels?: FilterByGroupedLabelsModel;
|
||||||
annotate?: () => void;
|
annotate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +92,13 @@ const renderActions = makeRenderLinksOrActions<ActionModel>(
|
|||||||
(item, i) => <ActionButton key={i} action={item} variant="secondary" />
|
(item, i) => <ActionButton key={i} action={item} variant="secondary" />
|
||||||
);
|
);
|
||||||
|
|
||||||
export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilters = [] }: VizTooltipFooterProps) => {
|
export const VizTooltipFooter = ({
|
||||||
|
dataLinks,
|
||||||
|
actions = [],
|
||||||
|
annotate,
|
||||||
|
adHocFilters = [],
|
||||||
|
filterByGroupedLabels,
|
||||||
|
}: VizTooltipFooterProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]);
|
const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]);
|
||||||
const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]);
|
const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]);
|
||||||
@@ -105,6 +118,39 @@ export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilte
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!hasOneClickLink && !hasOneClickAction && filterByGroupedLabels && (
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<Stack direction="column" gap={0.5} width="fit-content">
|
||||||
|
<Button
|
||||||
|
icon="filter"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={filterByGroupedLabels.onFilterForGroupedLabels}
|
||||||
|
>
|
||||||
|
<Trans
|
||||||
|
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-filter"
|
||||||
|
data-testid={selectors.components.VizTooltipFooter.buttons.apply}
|
||||||
|
>
|
||||||
|
Apply as filter
|
||||||
|
</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon="filter"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={filterByGroupedLabels.onFilterOutGroupedLabels}
|
||||||
|
>
|
||||||
|
<Trans
|
||||||
|
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-inverse-filter"
|
||||||
|
data-testid={selectors.components.VizTooltipFooter.buttons.applyInverse}
|
||||||
|
>
|
||||||
|
Apply as inverse filter
|
||||||
|
</Trans>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!hasOneClickLink && !hasOneClickAction && annotate != null && (
|
{!hasOneClickLink && !hasOneClickAction && annotate != null && (
|
||||||
<div className={styles.footerSection}>
|
<div className={styles.footerSection}>
|
||||||
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
|
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ export { EmotionPerfTest } from '../components/ThemeDemos/EmotionPerfTest';
|
|||||||
export { ThemeDemo } from '../components/ThemeDemos/ThemeDemo';
|
export { ThemeDemo } from '../components/ThemeDemos/ThemeDemo';
|
||||||
|
|
||||||
export { VizTooltipContent } from '../components/VizTooltip/VizTooltipContent';
|
export { VizTooltipContent } from '../components/VizTooltip/VizTooltipContent';
|
||||||
export { VizTooltipFooter, type AdHocFilterModel } from '../components/VizTooltip/VizTooltipFooter';
|
export {
|
||||||
|
VizTooltipFooter,
|
||||||
|
type AdHocFilterModel,
|
||||||
|
type FilterByGroupedLabelsModel,
|
||||||
|
} from '../components/VizTooltip/VizTooltipFooter';
|
||||||
export { VizTooltipHeader } from '../components/VizTooltip/VizTooltipHeader';
|
export { VizTooltipHeader } from '../components/VizTooltip/VizTooltipHeader';
|
||||||
export { VizTooltipWrapper } from '../components/VizTooltip/VizTooltipWrapper';
|
export { VizTooltipWrapper } from '../components/VizTooltip/VizTooltipWrapper';
|
||||||
export { VizTooltipRow } from '../components/VizTooltip/VizTooltipRow';
|
export { VizTooltipRow } from '../components/VizTooltip/VizTooltipRow';
|
||||||
|
|||||||
@@ -609,7 +609,14 @@ var (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "panelGroupBy",
|
Name: "panelGroupBy",
|
||||||
Description: "Enabled a group by action per panel",
|
Description: "Enables a group by action per panel",
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
FrontendOnly: true,
|
||||||
|
Owner: grafanaDashboardsSquad,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "perPanelFiltering",
|
||||||
|
Description: "Enables filtering by grouping labels on the panel level through legend or tooltip",
|
||||||
Stage: FeatureStageExperimental,
|
Stage: FeatureStageExperimental,
|
||||||
FrontendOnly: true,
|
FrontendOnly: true,
|
||||||
Owner: grafanaDashboardsSquad,
|
Owner: grafanaDashboardsSquad,
|
||||||
|
|||||||
Generated
+1
@@ -85,6 +85,7 @@ dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
|
|||||||
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
||||||
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
|
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
|
||||||
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
|
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
|
||||||
|
perPanelFiltering,experimental,@grafana/dashboards-squad,false,false,true
|
||||||
panelFilterVariable,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
|
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||||
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
||||||
|
|||||||
|
+299
-355
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { EventBusSrv } from '@grafana/data';
|
import { AdHocVariableModel, EventBusSrv, GroupByVariableModel, VariableModel } from '@grafana/data';
|
||||||
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
|
import { BackendSrv, config, setBackendSrv } from '@grafana/runtime';
|
||||||
import { PanelContext } from '@grafana/ui';
|
import { GroupByVariable, sceneGraph } from '@grafana/scenes';
|
||||||
|
import { AdHocFilterItem, PanelContext } from '@grafana/ui';
|
||||||
|
|
||||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||||
import { findVizPanelByKey } from '../utils/utils';
|
import { findVizPanelByKey } from '../utils/utils';
|
||||||
@@ -159,6 +160,146 @@ describe('setDashboardPanelContext', () => {
|
|||||||
expect(variable.state.filters[1].operator).toBe('!=');
|
expect(variable.state.filters[1].operator).toBe('!=');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getFiltersBasedOnGrouping', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
config.featureToggles.groupByVariable = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
config.featureToggles.groupByVariable = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return filters based on grouping', () => {
|
||||||
|
const { scene, context } = buildTestScene({ existingFilterVariable: true, existingGroupByVariable: true });
|
||||||
|
|
||||||
|
const groupBy = sceneGraph.getVariables(scene).state.variables.find((f) => f instanceof GroupByVariable);
|
||||||
|
|
||||||
|
groupBy?.changeValueTo(['container', 'cluster']);
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'container', value: 'container', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ key: 'container', value: 'container', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty filters if there is no groupBy selection', () => {
|
||||||
|
const { context } = buildTestScene({ existingFilterVariable: true, existingGroupByVariable: true });
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'container', value: 'container', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty filters if there is no groupBy variable', () => {
|
||||||
|
const { context } = buildTestScene({ existingFilterVariable: true, existingGroupByVariable: false });
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'container', value: 'container', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty filters if panel and groupBy ds differs', () => {
|
||||||
|
const { scene, context } = buildTestScene({
|
||||||
|
existingFilterVariable: true,
|
||||||
|
existingGroupByVariable: true,
|
||||||
|
groupByDatasourceUid: 'different-ds',
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupBy = sceneGraph.getVariables(scene).state.variables.find((f) => f instanceof GroupByVariable);
|
||||||
|
|
||||||
|
groupBy?.changeValueTo(['container', 'cluster']);
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'container', value: 'container', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onAddAdHocFilters', () => {
|
||||||
|
it('should add adhoc filters', () => {
|
||||||
|
const { scene, context } = buildTestScene({
|
||||||
|
existingFilterVariable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'existing', value: 'val', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
context.onAddAdHocFilters?.(filters);
|
||||||
|
expect(variable.state.filters).toEqual([
|
||||||
|
{ key: 'existing', value: 'val', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update and add adhoc filters', () => {
|
||||||
|
const { scene, context } = buildTestScene({
|
||||||
|
existingFilterVariable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||||
|
|
||||||
|
variable.setState({ filters: [{ key: 'existing', value: 'val', operator: '=' }] });
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'existing', value: 'val', operator: '!=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
context.onAddAdHocFilters?.(filters);
|
||||||
|
expect(variable.state.filters).toEqual([
|
||||||
|
{ key: 'existing', value: 'val', operator: '!=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not do anything if filters empty', () => {
|
||||||
|
const { scene, context } = buildTestScene({
|
||||||
|
existingFilterVariable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [];
|
||||||
|
|
||||||
|
context.onAddAdHocFilters?.(filters);
|
||||||
|
expect(variable.state.filters).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SceneOptions {
|
interface SceneOptions {
|
||||||
@@ -169,9 +310,29 @@ interface SceneOptions {
|
|||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
orgCanEdit?: boolean;
|
orgCanEdit?: boolean;
|
||||||
existingFilterVariable?: boolean;
|
existingFilterVariable?: boolean;
|
||||||
|
existingGroupByVariable?: boolean;
|
||||||
|
groupByDatasourceUid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTestScene(options: SceneOptions) {
|
function buildTestScene(options: SceneOptions) {
|
||||||
|
const varList: VariableModel[] = [];
|
||||||
|
|
||||||
|
if (options.existingFilterVariable) {
|
||||||
|
varList.push({
|
||||||
|
type: 'adhoc',
|
||||||
|
name: 'Filters',
|
||||||
|
datasource: { uid: 'my-ds-uid' },
|
||||||
|
} as AdHocVariableModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.existingGroupByVariable) {
|
||||||
|
varList.push({
|
||||||
|
type: 'groupby',
|
||||||
|
name: 'Group By',
|
||||||
|
datasource: { uid: options.groupByDatasourceUid ?? 'my-ds-uid', type: 'prometheus' },
|
||||||
|
} as GroupByVariableModel);
|
||||||
|
}
|
||||||
|
|
||||||
const scene = transformSaveModelToScene({
|
const scene = transformSaveModelToScene({
|
||||||
dashboard: {
|
dashboard: {
|
||||||
title: 'hello',
|
title: 'hello',
|
||||||
@@ -203,15 +364,7 @@ function buildTestScene(options: SceneOptions) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
templating: {
|
templating: {
|
||||||
list: options.existingFilterVariable
|
list: varList,
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'adhoc',
|
|
||||||
name: 'Filters',
|
|
||||||
datasource: { uid: 'my-ds-uid' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -133,6 +133,43 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
|
|||||||
updateAdHocFilterVariable(filterVar, newFilter);
|
updateAdHocFilterVariable(filterVar, newFilter);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
context.getFiltersBasedOnGrouping = (items: AdHocFilterItem[]) => {
|
||||||
|
const dashboard = getDashboardSceneFor(vizPanel);
|
||||||
|
|
||||||
|
const queryRunner = getQueryRunnerFor(vizPanel);
|
||||||
|
if (!queryRunner) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupByVar = getGroupByVariableFor(dashboard, queryRunner.state.datasource);
|
||||||
|
|
||||||
|
if (!groupByVar) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValues = Array.isArray(groupByVar.state.value)
|
||||||
|
? groupByVar.state.value
|
||||||
|
: groupByVar.state.value
|
||||||
|
? [groupByVar.state.value]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map((item) => (currentValues.find((key) => key === item.key) ? item : undefined))
|
||||||
|
.filter((item) => item !== undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
context.onAddAdHocFilters = (items: AdHocFilterItem[]) => {
|
||||||
|
const dashboard = getDashboardSceneFor(vizPanel);
|
||||||
|
|
||||||
|
const queryRunner = getQueryRunnerFor(vizPanel);
|
||||||
|
if (!queryRunner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
|
||||||
|
bulkUpdateAdHocFiltersVariable(filterVar, items);
|
||||||
|
};
|
||||||
|
|
||||||
context.canExecuteActions = () => {
|
context.canExecuteActions = () => {
|
||||||
const dashboard = getDashboardSceneFor(vizPanel);
|
const dashboard = getDashboardSceneFor(vizPanel);
|
||||||
return dashboard.canEditDashboard();
|
return dashboard.canEditDashboard();
|
||||||
@@ -167,6 +204,21 @@ function reRunBuiltInAnnotationsLayer(scene: DashboardScene) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGroupByVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
|
||||||
|
const variables = sceneGraph.getVariables(scene);
|
||||||
|
|
||||||
|
for (const variable of variables.state.variables) {
|
||||||
|
if (sceneUtils.isGroupByVariable(variable)) {
|
||||||
|
const filtersDs = variable.state.datasource;
|
||||||
|
if (filtersDs === ds || filtersDs?.uid === ds?.uid) {
|
||||||
|
return variable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
|
export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
|
||||||
const variables = sceneGraph.getVariables(scene);
|
const variables = sceneGraph.getVariables(scene);
|
||||||
|
|
||||||
@@ -195,6 +247,35 @@ export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceR
|
|||||||
return newVariable;
|
return newVariable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bulkUpdateAdHocFiltersVariable(filterVar: AdHocFiltersVariable, newFilters: AdHocFilterItem[]) {
|
||||||
|
if (!newFilters.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedFilters = filterVar.state.filters.slice();
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
for (const newFilter of newFilters) {
|
||||||
|
const filterToReplaceIndex = updatedFilters.findIndex(
|
||||||
|
(filter) =>
|
||||||
|
filter.key === newFilter.key && filter.value === newFilter.value && filter.operator !== newFilter.operator
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filterToReplaceIndex >= 0) {
|
||||||
|
updatedFilters.splice(filterToReplaceIndex, 1, newFilter);
|
||||||
|
hasChanges = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedFilters.push(newFilter);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
filterVar.updateFilters(updatedFilters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateAdHocFilterVariable(filterVar: AdHocFiltersVariable, newFilter: AdHocFilterItem) {
|
function updateAdHocFilterVariable(filterVar: AdHocFiltersVariable, newFilter: AdHocFilterItem) {
|
||||||
// This function handles 'Filter for value' and 'Filter out value' from table cell
|
// This function handles 'Filter for value' and 'Filter out value' from table cell
|
||||||
// We are allowing to add filters with the same key because elastic search ds supports that
|
// We are allowing to add filters with the same key because elastic search ds supports that
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
XAxisInteractionAreaPlugin,
|
XAxisInteractionAreaPlugin,
|
||||||
usePanelContext,
|
usePanelContext,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
|
import { FILTER_OUT_OPERATOR, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
|
||||||
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
|
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
|
||||||
import { config } from 'app/core/config';
|
import { config } from 'app/core/config';
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ import { OutsideRangePlugin } from './plugins/OutsideRangePlugin';
|
|||||||
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
|
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
|
||||||
import { getXAnnotationFrames } from './plugins/utils';
|
import { getXAnnotationFrames } from './plugins/utils';
|
||||||
import { getPrepareTimeseriesSuggestion } from './suggestions';
|
import { getPrepareTimeseriesSuggestion } from './suggestions';
|
||||||
import { getTimezones, prepareGraphableFields } from './utils';
|
import { getGroupedFilters, getTimezones, prepareGraphableFields } from './utils';
|
||||||
|
|
||||||
interface TimeSeriesPanelProps extends PanelProps<Options> {}
|
interface TimeSeriesPanelProps extends PanelProps<Options> {}
|
||||||
|
|
||||||
@@ -56,6 +56,8 @@ export const TimeSeriesPanel = ({
|
|||||||
showThresholds,
|
showThresholds,
|
||||||
eventBus,
|
eventBus,
|
||||||
canExecuteActions,
|
canExecuteActions,
|
||||||
|
getFiltersBasedOnGrouping,
|
||||||
|
onAddAdHocFilters,
|
||||||
} = usePanelContext();
|
} = usePanelContext();
|
||||||
|
|
||||||
const { dataLinkPostProcessor } = useDataLinksContext();
|
const { dataLinkPostProcessor } = useDataLinksContext();
|
||||||
@@ -175,6 +177,11 @@ export const TimeSeriesPanel = ({
|
|||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const groupingFilters =
|
||||||
|
seriesIdx !== null && config.featureToggles.perPanelFiltering && getFiltersBasedOnGrouping
|
||||||
|
? getGroupedFilters(alignedFrame, seriesIdx, getFiltersBasedOnGrouping)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
|
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
|
||||||
<TimeSeriesTooltip
|
<TimeSeriesTooltip
|
||||||
@@ -189,6 +196,17 @@ export const TimeSeriesPanel = ({
|
|||||||
maxHeight={options.tooltip.maxHeight}
|
maxHeight={options.tooltip.maxHeight}
|
||||||
replaceVariables={replaceVariables}
|
replaceVariables={replaceVariables}
|
||||||
dataLinks={dataLinks}
|
dataLinks={dataLinks}
|
||||||
|
filterByGroupedLabels={
|
||||||
|
config.featureToggles.perPanelFiltering && groupingFilters.length && onAddAdHocFilters
|
||||||
|
? {
|
||||||
|
onFilterForGroupedLabels: () => onAddAdHocFilters(groupingFilters),
|
||||||
|
onFilterOutGroupedLabels: () =>
|
||||||
|
onAddAdHocFilters(
|
||||||
|
groupingFilters.map((item) => ({ ...item, operator: FILTER_OUT_OPERATOR }))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
canExecuteActions={userCanExecuteActions}
|
canExecuteActions={userCanExecuteActions}
|
||||||
compareDiffMs={compareDiffMs}
|
compareDiffMs={compareDiffMs}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getContentItems,
|
getContentItems,
|
||||||
VizTooltipItem,
|
VizTooltipItem,
|
||||||
AdHocFilterModel,
|
AdHocFilterModel,
|
||||||
|
FilterByGroupedLabelsModel,
|
||||||
} from '@grafana/ui/internal';
|
} from '@grafana/ui/internal';
|
||||||
|
|
||||||
import { getFieldActions } from '../status-history/utils';
|
import { getFieldActions } from '../status-history/utils';
|
||||||
@@ -50,6 +51,7 @@ export interface TimeSeriesTooltipProps {
|
|||||||
dataLinks: LinkModel[];
|
dataLinks: LinkModel[];
|
||||||
hideZeros?: boolean;
|
hideZeros?: boolean;
|
||||||
adHocFilters?: AdHocFilterModel[];
|
adHocFilters?: AdHocFilterModel[];
|
||||||
|
filterByGroupedLabels?: FilterByGroupedLabelsModel;
|
||||||
canExecuteActions?: boolean;
|
canExecuteActions?: boolean;
|
||||||
compareDiffMs?: number[];
|
compareDiffMs?: number[];
|
||||||
}
|
}
|
||||||
@@ -70,8 +72,10 @@ export const TimeSeriesTooltip = ({
|
|||||||
adHocFilters,
|
adHocFilters,
|
||||||
canExecuteActions,
|
canExecuteActions,
|
||||||
compareDiffMs,
|
compareDiffMs,
|
||||||
|
filterByGroupedLabels,
|
||||||
}: TimeSeriesTooltipProps) => {
|
}: TimeSeriesTooltipProps) => {
|
||||||
const pluginContext = usePluginContext();
|
const pluginContext = usePluginContext();
|
||||||
|
|
||||||
const xField = series.fields[0];
|
const xField = series.fields[0];
|
||||||
let xVal = xField.values[dataIdxs[0]!];
|
let xVal = xField.values[dataIdxs[0]!];
|
||||||
|
|
||||||
@@ -107,7 +111,13 @@ export const TimeSeriesTooltip = ({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
footer = (
|
footer = (
|
||||||
<VizTooltipFooter dataLinks={dataLinks} actions={actions} annotate={annotate} adHocFilters={adHocFilters} />
|
<VizTooltipFooter
|
||||||
|
dataLinks={dataLinks}
|
||||||
|
actions={actions}
|
||||||
|
annotate={annotate}
|
||||||
|
adHocFilters={adHocFilters}
|
||||||
|
filterByGroupedLabels={filterByGroupedLabels}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createTheme, FieldType, createDataFrame, toDataFrame } from '@grafana/data';
|
import { createTheme, FieldType, createDataFrame, toDataFrame } from '@grafana/data';
|
||||||
import { LineInterpolation } from '@grafana/ui';
|
import { LineInterpolation } from '@grafana/ui';
|
||||||
|
|
||||||
import { prepareGraphableFields } from './utils';
|
import { AdHocFilterItem } from '../../../../../packages/grafana-ui/src/components/Table/TableNG/types';
|
||||||
|
|
||||||
|
import { getGroupedFilters, prepareGraphableFields } from './utils';
|
||||||
|
|
||||||
describe('prepare timeseries graph', () => {
|
describe('prepare timeseries graph', () => {
|
||||||
it('errors with no time fields', () => {
|
it('errors with no time fields', () => {
|
||||||
@@ -178,4 +180,83 @@ describe('prepare timeseries graph', () => {
|
|||||||
expect(frames![0].fields[1].config.custom.lineInterpolation).toEqual(LineInterpolation.StepAfter);
|
expect(frames![0].fields[1].config.custom.lineInterpolation).toEqual(LineInterpolation.StepAfter);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getGroupedFilters', () => {
|
||||||
|
it('returns empty array if no field', () => {
|
||||||
|
const df = createDataFrame({
|
||||||
|
fields: [{ name: 'time', type: FieldType.time, values: [1, 2, 3] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getGroupedFilters(df, 1, jest.fn())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array if no labels', () => {
|
||||||
|
const df = createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 2, 3],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getGroupedFilters(df, 1, jest.fn())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array if field not filterable', () => {
|
||||||
|
const df = createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 2, 3],
|
||||||
|
labels: {
|
||||||
|
test: 'value',
|
||||||
|
label: 'value2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getGroupedFilters(df, 1, jest.fn())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns grouped filters', () => {
|
||||||
|
const df = createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 2, 3],
|
||||||
|
labels: {
|
||||||
|
test: 'value',
|
||||||
|
label: 'value2',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
filterable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtersGroupingFn = (filters: AdHocFilterItem[]) => filters;
|
||||||
|
|
||||||
|
expect(getGroupedFilters(df, 1, filtersGroupingFn)).toEqual([
|
||||||
|
{
|
||||||
|
key: 'test',
|
||||||
|
operator: '=',
|
||||||
|
value: 'value',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'label',
|
||||||
|
operator: '=',
|
||||||
|
value: 'value2',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { convertFieldType } from '@grafana/data/internal';
|
import { convertFieldType } from '@grafana/data/internal';
|
||||||
import { GraphFieldConfig, LineInterpolation, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
|
import { GraphFieldConfig, LineInterpolation, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
|
||||||
import { buildScaleKey } from '@grafana/ui/internal';
|
import { AdHocFilterItem } from '@grafana/ui';
|
||||||
|
import { buildScaleKey, FILTER_FOR_OPERATOR } from '@grafana/ui/internal';
|
||||||
|
|
||||||
import { HeatmapTooltip } from '../heatmap/panelcfg.gen';
|
import { HeatmapTooltip } from '../heatmap/panelcfg.gen';
|
||||||
|
|
||||||
@@ -329,3 +330,28 @@ export function getTimezones(timezones: string[] | undefined, defaultTimezone: s
|
|||||||
export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions | HeatmapTooltip) => {
|
export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions | HeatmapTooltip) => {
|
||||||
return tooltipOptions.mode === TooltipDisplayMode.Multi && tooltipOptions.maxHeight != null;
|
return tooltipOptions.mode === TooltipDisplayMode.Multi && tooltipOptions.maxHeight != null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getGroupedFilters(
|
||||||
|
frame: DataFrame,
|
||||||
|
seriesIdx: number,
|
||||||
|
getFiltersBasedOnGrouping: (filters: AdHocFilterItem[]) => AdHocFilterItem[]
|
||||||
|
) {
|
||||||
|
const groupingFilters: AdHocFilterItem[] = [];
|
||||||
|
const xField = frame.fields[seriesIdx];
|
||||||
|
|
||||||
|
if (xField && xField.labels && xField.config.filterable) {
|
||||||
|
const seriesFilters: AdHocFilterItem[] = [];
|
||||||
|
|
||||||
|
Object.entries(xField.labels).forEach(([key, value]) => {
|
||||||
|
seriesFilters.push({
|
||||||
|
key,
|
||||||
|
operator: FILTER_FOR_OPERATOR,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
groupingFilters.push(...getFiltersBasedOnGrouping(seriesFilters));
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupingFilters;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9257,6 +9257,8 @@
|
|||||||
"actions-confirmation-label": "Confirmation message",
|
"actions-confirmation-label": "Confirmation message",
|
||||||
"actions-confirmation-message": "Provide a descriptive prompt to confirm or cancel the action.",
|
"actions-confirmation-message": "Provide a descriptive prompt to confirm or cancel the action.",
|
||||||
"footer-add-annotation": "Add annotation",
|
"footer-add-annotation": "Add annotation",
|
||||||
|
"footer-apply-series-as-filter": "Apply as filter",
|
||||||
|
"footer-apply-series-as-inverse-filter": "Apply as inverse filter",
|
||||||
"footer-click-to-action": "Click to {{actionTitle}}",
|
"footer-click-to-action": "Click to {{actionTitle}}",
|
||||||
"footer-click-to-navigate": "Click to open {{linkTitle}}",
|
"footer-click-to-navigate": "Click to open {{linkTitle}}",
|
||||||
"footer-filter-for-value": "Filter for '{{value}}'",
|
"footer-filter-for-value": "Filter for '{{value}}'",
|
||||||
|
|||||||
Reference in New Issue
Block a user