PanelEdit: v8 Panel Edit UX (#32124)

* Initial commit

* Progress

* Update

* Progress

* updates

* Minor fix

* fixed ts issue

* fixed e2e tests

* More explorations

* Making progress

* Panel options and field options unified

* With nested categories

* Starting to find something

* fix paddings

* Progress

* Breakthrough ux layout

* Progress

* Updates

* New way of composing options with search

* added regex search

* Refactoring to react note tree

* Show overrides

* Adding overrides radio button support

* Added popular view

* Separate stat/gauge/bargauge options into value options and display options

* Initial work on getting library panels into viz picker flow

* Fixed issues switching to panel library panel

* Move search input put of LibraryPanelsView

* Changing design again to have content inside boxes

* Style updates

* Refactoring to fix scroll issue

* Option category naming

* Fixed FilterInput issue

* Updated snapshots

* Fix padding

* Updated viz picker design

* Unify library panel an viz picker card

* Updated card with delete action

* Major refactoring back to an object model instead of searching and filtering react node tree

* More refactoring

* Show option category in label when searching

* Nice logic for categories rendering when searching or when only child

* Make getSuggestions more lazy for DataLinksEditor

* Add missing repeat options and handle conditional options

* Prepping options category to be more flexibly and control state from outside

* Added option count to search result

* Minor style tweak

* Added button to close viz picker

* Rewrote overrides to enable searching overrides

* New search engine and tests

* Searching overrides works

* Hide radio buttons while searching

* Added angular options back

* Added memoize for all options so they are not rebuilt for every search key stroke

* Added back support for category counters

* Started unit test work

* Refactoring and base popular options list

* Initial update to e2e test, more coming to add e2e test for search features

* Minor fix

* Review updates

* Fixing category open states

* Unit test progress

* Do not show visualization list mode radio button if library panels is not enabled

* Use boolean

* More unit tests

* Increase library panels per page count and give search focus when switching list mode

* field config change test and search test

* Feedback updates

* Minor tweaks

* Minor refactorings

* More minimal override collapse state
This commit is contained in:
Torkel Ödegaard 2021-03-25 08:33:13 +01:00 committed by GitHub
parent ea186947d2
commit 9d6c8f8512
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 2437 additions and 2292 deletions

View File

@ -54,26 +54,18 @@ e2e.scenario({
// Panel sidebar is rendered open by default
e2e.components.PanelEditor.OptionsPane.content().should('be.visible');
// Can toggle on/off sidebar
e2e.components.PanelEditor.OptionsPane.close().should('be.visible');
e2e.components.PanelEditor.OptionsPane.open().should('not.exist');
// close options pane
e2e.components.PanelEditor.OptionsPane.close().click();
e2e.components.PanelEditor.OptionsPane.open().should('be.visible');
e2e.components.PanelEditor.OptionsPane.close().should('not.exist');
e2e.components.PanelEditor.toggleVizOptions().click();
e2e.components.PanelEditor.OptionsPane.content().should('not.exist');
e2e().wait(100);
// open options pane
e2e.components.PanelEditor.OptionsPane.open().click();
e2e.components.PanelEditor.OptionsPane.close().should('be.visible');
e2e.components.PanelEditor.OptionsPane.open().should('not.exist');
e2e.components.PanelEditor.toggleVizOptions().should('be.visible').click();
e2e.components.PanelEditor.OptionsPane.content().should('be.visible');
// Can change visualisation type
e2e.components.OptionsGroup.toggle('Panel type').should('be.visible').click();
// Check that Graph is chosen
e2e.components.PanelEditor.toggleVizPicker().click();
e2e.components.PluginVisualization.item('Graph').should('be.visible');
e2e.components.PluginVisualization.current().within((div: JQuery<HTMLDivElement>) => {
expect(div.text()).equals('Graph');
@ -94,21 +86,14 @@ e2e.scenario({
expect(div.text()).equals('Table');
});
// close viz picker
e2e.components.PanelEditor.toggleVizPicker().click();
// Data pane should be rendered
e2e.components.PanelEditor.DataPane.content().should('be.visible');
// Field & Overrides tabs (need to switch to React based vis, i.e. Table)
e2e.components.PanelEditor.OptionsPane.tab('Field').should('be.visible');
e2e.components.PanelEditor.OptionsPane.tab('Overrides').should('be.visible');
e2e.components.PanelEditor.OptionsPane.tab('Field').click();
e2e.components.FieldConfigEditor.content().should('be.visible');
e2e.components.OverridesConfigEditor.content().should('not.exist');
e2e.components.PanelEditor.OptionsPane.tab('Field').should('be.visible');
e2e.components.PanelEditor.OptionsPane.tab('Overrides').should('be.visible').click();
e2e.components.OverridesConfigEditor.content().should('be.visible');
e2e.components.FieldConfigEditor.content().should('not.exist');
e2e.components.PanelEditor.OptionsPane.fieldLabel('Table Show header').should('be.visible');
e2e.components.PanelEditor.OptionsPane.fieldLabel('Table Column width').should('be.visible');
},
});

View File

@ -5,7 +5,7 @@ import { DataFrame, InterpolateFunction, VariableSuggestionsScope, VariableSugge
import { EventBus } from '../events';
export interface StandardEditorContext<TOptions> {
data?: DataFrame[]; // All results
data: DataFrame[]; // All results
replaceVariables?: InterpolateFunction;
eventBus?: EventBus;
getSuggestions?: (scope?: VariableSuggestionsScope) => VariableSuggestion[];

View File

@ -58,7 +58,6 @@ export interface FieldConfigSource<TOptions extends object = any> {
export interface FieldOverrideContext extends StandardEditorContext<any> {
field?: Field;
dataFrameIndex?: number; // The index for the selected field frame
data: DataFrame[]; // All results
}
export interface FieldConfigEditorProps<TValue, TSettings>
extends Omit<StandardEditorProps<TValue, TSettings>, 'item'> {

View File

@ -41,7 +41,7 @@ export interface OptionEditorConfig<TOptions, TSettings = any, TValue = any> {
/**
* Array of strings representing category of the option. First element in the array will make option render as collapsible section.
*/
category?: Array<string | undefined>;
category?: string[];
/**
* Set this value if undefined

View File

@ -61,18 +61,15 @@ export const Components = {
},
OptionsPane: {
content: 'Panel editor option pane content',
close: 'Page toolbar button Close options pane',
open: 'Page toolbar button Open options pane',
select: 'Panel editor option pane select',
tab: (title: string) => `Panel editor option pane tab ${title}`,
fieldLabel: (type: string) => `${type} field property editor`,
},
// not sure about the naming *DataPane*
DataPane: {
content: 'Panel editor data pane content',
},
FieldOptions: {
propertyEditor: (type: string) => `${type} field property editor`,
},
toggleVizPicker: 'toggle-viz-picker',
toggleVizOptions: 'toggle-viz-options',
},
PanelInspector: {
Data: {

View File

@ -219,7 +219,7 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC
const closeOptions = (): any =>
isOptionsOpen().then((isOpen: any) => {
if (isOpen) {
e2e.components.PanelEditor.OptionsPane.close().click();
e2e.components.PanelEditor.toggleVizOptions().click();
}
});
@ -271,7 +271,7 @@ const isOptionsOpen = (): any =>
const openOptions = (): any =>
isOptionsOpen().then((isOpen: any) => {
if (!isOpen) {
e2e.components.PanelEditor.OptionsPane.open().click();
e2e.components.PanelEditor.toggleVizOptions().click();
}
});

View File

@ -208,6 +208,11 @@ export function getPropertiesForVariant(theme: GrafanaTheme, variant: ButtonVari
outline: none;
text-decoration: underline;
}
&:hover {
color: ${theme.colors.linkExternal};
text-decoration: underline;
}
`,
};

View File

@ -8,7 +8,7 @@ interface DataLinkEditorModalContentProps {
link: DataLink;
index: number;
data: DataFrame[];
suggestions: VariableSuggestion[];
getSuggestions: () => VariableSuggestion[];
onSave: (index: number, ink: DataLink) => void;
onCancel: (index: number) => void;
}
@ -16,7 +16,7 @@ interface DataLinkEditorModalContentProps {
export const DataLinkEditorModalContent: FC<DataLinkEditorModalContentProps> = ({
link,
index,
suggestions,
getSuggestions,
onSave,
onCancel,
}) => {
@ -27,7 +27,7 @@ export const DataLinkEditorModalContent: FC<DataLinkEditorModalContentProps> = (
value={dirtyLink}
index={index}
isLast={false}
suggestions={suggestions}
suggestions={getSuggestions()}
onChange={(index, link) => {
setDirtyLink(link);
}}

View File

@ -11,11 +11,16 @@ import { DataLinkEditorModalContent } from './DataLinkEditorModalContent';
interface DataLinksInlineEditorProps {
links?: DataLink[];
onChange: (links: DataLink[]) => void;
suggestions: VariableSuggestion[];
getSuggestions: () => VariableSuggestion[];
data: DataFrame[];
}
export const DataLinksInlineEditor: React.FC<DataLinksInlineEditorProps> = ({ links, onChange, suggestions, data }) => {
export const DataLinksInlineEditor: React.FC<DataLinksInlineEditorProps> = ({
links,
onChange,
getSuggestions,
data,
}) => {
const theme = useTheme();
const [editIndex, setEditIndex] = useState<number | null>(null);
const [isNew, setIsNew] = useState(false);
@ -74,7 +79,6 @@ export const DataLinksInlineEditor: React.FC<DataLinksInlineEditorProps> = ({ li
onEdit={() => setEditIndex(i)}
onRemove={() => onDataLinkRemove(i)}
data={data}
suggestions={suggestions}
/>
);
})}
@ -95,7 +99,7 @@ export const DataLinksInlineEditor: React.FC<DataLinksInlineEditorProps> = ({ li
data={data}
onSave={onDataLinkChange}
onCancel={onDataLinkCancel}
suggestions={suggestions}
getSuggestions={getSuggestions}
/>
</Modal>
)}

View File

@ -17,7 +17,6 @@ function setupTestContext(options: Partial<DataLinksListItemProps>) {
onChange: jest.fn(),
onEdit: jest.fn(),
onRemove: jest.fn(),
suggestions: [],
};
const props = { ...defaults, ...options };

View File

@ -1,6 +1,6 @@
import React, { FC } from 'react';
import { css, cx } from 'emotion';
import { DataFrame, DataLink, GrafanaTheme, VariableSuggestion } from '@grafana/data';
import { DataFrame, DataLink, GrafanaTheme } from '@grafana/data';
import { stylesFactory, useTheme } from '../../../themes';
import { HorizontalGroup, VerticalGroup } from '../../Layout/Layout';
import { IconButton } from '../../IconButton/IconButton';
@ -12,7 +12,6 @@ export interface DataLinksListItemProps {
onChange: (index: number, link: DataLink) => void;
onEdit: () => void;
onRemove: () => void;
suggestions: VariableSuggestion[];
isEditing?: boolean;
}

View File

@ -88,7 +88,6 @@ const getInfoBoxStyles = stylesFactory((theme: GrafanaTheme, severity: AlertVari
padding: ${theme.spacing.md};
border-radius: ${theme.border.radius.md};
position: relative;
box-shadow: 0 0 30px 10px rgba(0, 0, 0, ${theme.isLight ? 0.05 : 0.2});
z-index: 0;
&:before {

View File

@ -17,7 +17,7 @@ export const DataLinksValueEditor: React.FC<FieldConfigEditorProps<DataLink[], D
links={value}
onChange={onChange}
data={context.data}
suggestions={context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : []}
getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])}
/>
);
};

View File

@ -8,7 +8,6 @@ export const SliderValueEditor: React.FC<FieldConfigEditorProps<number, SliderFi
item,
}) => {
const { settings } = item;
const initialValue = typeof value === 'number' ? value : typeof value === 'string' ? +value : 0;
return (

View File

@ -135,7 +135,7 @@ const getStyles = (theme: GrafanaTheme) => {
background: ${theme.colors.dashboardBg};
justify-content: flex-end;
flex-wrap: wrap;
padding: 0 ${spacing.md} ${spacing.sm} ${spacing.md};
padding: 0 ${spacing.sm} ${spacing.sm} ${spacing.md};
`,
toolbarLeft: css`
display: flex;

View File

@ -221,7 +221,7 @@ export const getStandardFieldConfigs = () => {
category,
};
return [unit, min, max, decimals, displayName, noValue, color, thresholds, mappings, links];
return [unit, min, max, decimals, displayName, color, noValue, thresholds, mappings, links];
};
/**

View File

@ -268,16 +268,18 @@ func getPanelSort(id string) int {
sort = 6
case "singlestat":
sort = 7
case "text":
case "piechart":
sort = 8
case "heatmap":
case "text":
sort = 9
case "alertlist":
case "heatmap":
sort = 10
case "dashlist":
case "alertlist":
sort = 11
case "news":
case "dashlist":
sort = 12
case "news":
sort = 13
}
return sort
}

View File

@ -1,24 +1,33 @@
import React, { FC } from 'react';
import { escapeStringForRegex, unEscapeStringFromRegex } from '@grafana/data';
import { Input, Icon } from '@grafana/ui';
import { Input, Icon, Button } from '@grafana/ui';
export interface Props {
value: string | undefined;
placeholder?: string;
labelClassName?: string;
inputClassName?: string;
width?: number;
onChange: (value: string) => void;
autoFocus?: boolean;
}
export const FilterInput: FC<Props> = (props) => (
<Input
// Replaces the usage of ref
autoFocus
prefix={<Icon name="search" />}
width={40}
type="text"
value={props.value ? unEscapeStringFromRegex(props.value) : ''}
onChange={(event) => props.onChange(escapeStringForRegex(event.currentTarget.value))}
placeholder={props.placeholder ?? ''}
/>
);
export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, autoFocus }) => {
const suffix =
value !== '' ? (
<Button icon="times" variant="link" size="sm" onClick={() => onChange('')}>
Clear
</Button>
) : null;
return (
<Input
autoFocus={autoFocus ?? false}
prefix={<Icon name="search" />}
suffix={suffix}
width={width ?? 40}
type="text"
value={value ? unEscapeStringFromRegex(value) : ''}
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))}
placeholder={placeholder}
/>
);
};

View File

@ -21,13 +21,7 @@ export default class OrgActionBar extends PureComponent<Props> {
return (
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<FilterInput
labelClassName="gf-form--has-input-icon"
inputClassName="gf-form-input width-20"
value={searchQuery}
onChange={setSearchQuery}
placeholder={'Search by name or type'}
/>
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={'Search by name or type'} />
</div>
<div className="page-action-bar__spacer" />
<LinkButton {...linkProps}>{linkButton.title}</LinkButton>

View File

@ -8,8 +8,6 @@ exports[`Render should render component 1`] = `
className="gf-form gf-form--grow"
>
<FilterInput
inputClassName="gf-form-input width-20"
labelClassName="gf-form--has-input-icon"
onChange={[MockFunction]}
placeholder="Search by name or type"
value=""

View File

@ -106,13 +106,7 @@ export class AlertRuleListUnconnected extends PureComponent<Props> {
<Page.Contents isLoading={isLoading}>
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<FilterInput
labelClassName="gf-form--has-input-icon gf-form--grow"
inputClassName="gf-form-input"
placeholder="Search alerts"
value={search}
onChange={this.onSearchQueryChange}
/>
<FilterInput placeholder="Search alerts" value={search} onChange={this.onSearchQueryChange} />
</div>
<div className="gf-form">
<label className="gf-form-label">States</label>

View File

@ -13,13 +13,7 @@ export const ApiKeysActionBar: FC<Props> = ({ searchQuery, disabled, onAddClick,
return (
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<FilterInput
labelClassName="gf-form--has-input-icon gf-form--grow"
inputClassName="gf-form-input"
placeholder="Search keys"
value={searchQuery}
onChange={onSearchChange}
/>
<FilterInput placeholder="Search keys" value={searchQuery} onChange={onSearchChange} />
</div>
<div className="page-action-bar__spacer" />

View File

@ -6,7 +6,7 @@ import tinycolor from 'tinycolor2';
import { locationService } from '@grafana/runtime';
import { Icon, IconButton, styleMixins, useStyles } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { DateTimeInput, GrafanaTheme } from '@grafana/data';
import { GrafanaTheme } from '@grafana/data';
import config from 'app/core/config';
import store from 'app/core/store';
@ -140,9 +140,9 @@ export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard })
{addPanelView ? (
<LibraryPanelsView
className={styles.libraryPanelsWrapper}
formatDate={(dateString: DateTimeInput) => dashboard.formatDate(dateString, 'L')}
onClickCard={(panel) => onAddLibraryPanel(panel)}
showSecondaryActions={false}
searchString={''}
/>
) : (
<div className={styles.actionsWrapper}>

View File

@ -114,6 +114,7 @@ export class AngularPanelOptionsUnconnected extends PureComponent<Props> {
};
this.angularOptions = loader.load(this.element, scopeProps, template);
this.angularOptions.digest();
}
render() {

View File

@ -1,263 +0,0 @@
import React from 'react';
import { render } from '@testing-library/react';
import { DefaultFieldConfigEditor } from './DefaultFieldConfigEditor';
import {
FieldConfigEditorConfig,
FieldConfigSource,
PanelPlugin,
standardEditorsRegistry,
standardFieldConfigEditorRegistry,
} from '@grafana/data';
import { mockStandardFieldConfigOptions } from '../../../../../test/helpers/fieldConfig';
import { selectors } from '@grafana/e2e-selectors';
interface FakeFieldOptions {
a: boolean;
b: string;
c: boolean;
}
standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions());
standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions());
const fieldConfigMock: FieldConfigSource<FakeFieldOptions> = {
defaults: {
custom: {
a: true,
b: 'test',
c: true,
},
},
overrides: [],
};
describe('DefaultFieldConfigEditor', () => {
it('should render custom options', () => {
const plugin = new PanelPlugin(() => null).useFieldConfig({
standardOptions: {},
useCustomConfig: (b) => {
b.addBooleanSwitch({
name: 'a',
path: 'a',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addBooleanSwitch({
name: 'c',
path: 'c',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addTextInput({
name: 'b',
path: 'b',
} as FieldConfigEditorConfig<FakeFieldOptions>);
},
});
const { queryAllByLabelText } = render(
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
);
const editors = queryAllByLabelText(selectors.components.PanelEditor.FieldOptions.propertyEditor('Custom'));
expect(editors).toHaveLength(3);
});
it('should not render options that are marked as hidden from defaults', () => {
const plugin = new PanelPlugin(() => null).useFieldConfig({
standardOptions: {},
useCustomConfig: (b) => {
b.addBooleanSwitch({
name: 'a',
path: 'a',
hideFromDefaults: true,
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addBooleanSwitch({
name: 'c',
path: 'c',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addTextInput({
name: 'b',
path: 'b',
} as FieldConfigEditorConfig<FakeFieldOptions>);
},
});
const { queryAllByLabelText } = render(
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
);
const editors = queryAllByLabelText(selectors.components.PanelEditor.FieldOptions.propertyEditor('Custom'));
expect(editors).toHaveLength(2);
});
describe('categories', () => {
it('should render uncategorized options under panel category', () => {
const plugin = new PanelPlugin(() => null).useFieldConfig({
standardOptions: {},
useCustomConfig: (b) => {
b.addBooleanSwitch({
name: 'a',
path: 'a',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addBooleanSwitch({
name: 'c',
path: 'c',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addTextInput({
name: 'b',
path: 'b',
} as FieldConfigEditorConfig<FakeFieldOptions>);
},
});
plugin.meta.name = 'Test plugin';
const { queryAllByLabelText } = render(
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
);
expect(
queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${plugin.meta.name} options/0`))
).toHaveLength(1);
expect(queryAllByLabelText(selectors.components.OptionsGroup.toggle(), { exact: false })).toHaveLength(1);
});
it('should render categorized options under custom category', () => {
const CATEGORY_NAME = 'Cat1';
const plugin = new PanelPlugin(() => null).useFieldConfig({
standardOptions: {},
useCustomConfig: (b) => {
b.addTextInput({
name: 'b',
path: 'b',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addBooleanSwitch({
name: 'a',
path: 'a',
category: [CATEGORY_NAME],
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addBooleanSwitch({
name: 'c',
path: 'c',
category: [CATEGORY_NAME],
} as FieldConfigEditorConfig<FakeFieldOptions>);
},
});
plugin.meta.name = 'Test plugin';
const { queryAllByLabelText } = render(
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
);
expect(
queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${plugin.meta.name} options/0`))
).toHaveLength(1);
expect(queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${CATEGORY_NAME}/1`))).toHaveLength(1);
expect(queryAllByLabelText(selectors.components.OptionsGroup.toggle(), { exact: false })).toHaveLength(2);
});
it('should allow subcategories in panel category', () => {
const SUBCATEGORY_NAME = 'Sub1';
const plugin = new PanelPlugin(() => null).useFieldConfig({
standardOptions: {},
useCustomConfig: (b) => {
b.addTextInput({
name: 'b',
path: 'b',
category: [undefined, SUBCATEGORY_NAME],
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addBooleanSwitch({
name: 'a',
path: 'a',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addBooleanSwitch({
name: 'c',
path: 'c',
} as FieldConfigEditorConfig<FakeFieldOptions>);
},
});
plugin.meta.name = 'Test plugin';
const { queryAllByLabelText, queryAllByText } = render(
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
);
expect(
queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${plugin.meta.name} options/0`))
).toHaveLength(1);
expect(queryAllByText(SUBCATEGORY_NAME, { exact: false })).toHaveLength(1);
});
it('should allow subcategories in custom category', () => {
const CATEGORY_NAME = 'Cat1';
const SUBCATEGORY_NAME = 'Sub1';
const plugin = new PanelPlugin(() => null).useFieldConfig({
standardOptions: {},
useCustomConfig: (b) => {
b.addBooleanSwitch({
name: 'a',
path: 'a',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addBooleanSwitch({
name: 'c',
path: 'c',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addTextInput({
name: 'b',
path: 'b',
category: [CATEGORY_NAME, SUBCATEGORY_NAME],
} as FieldConfigEditorConfig<FakeFieldOptions>);
},
});
plugin.meta.name = 'Test plugin';
const { queryAllByLabelText, queryAllByText } = render(
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
);
expect(
queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${plugin.meta.name} options/0`))
).toHaveLength(1);
expect(queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${CATEGORY_NAME}/1`))).toHaveLength(1);
expect(queryAllByText(SUBCATEGORY_NAME, { exact: false })).toHaveLength(1);
});
it('should not render categories with hidden fields only', () => {
const CATEGORY_NAME = 'Cat1';
const SUBCATEGORY_NAME = 'Sub1';
const plugin = new PanelPlugin(() => null).useFieldConfig({
standardOptions: {},
useCustomConfig: (b) => {
b.addBooleanSwitch({
name: 'a',
path: 'a',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addBooleanSwitch({
name: 'c',
path: 'c',
} as FieldConfigEditorConfig<FakeFieldOptions>)
.addTextInput({
name: 'b',
path: 'b',
hideFromDefaults: true,
category: [CATEGORY_NAME, SUBCATEGORY_NAME],
} as FieldConfigEditorConfig<FakeFieldOptions>);
},
});
plugin.meta.name = 'Test plugin';
const { queryAllByLabelText, queryAllByText } = render(
<DefaultFieldConfigEditor data={[]} onChange={jest.fn()} plugin={plugin} config={fieldConfigMock} />
);
expect(
queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${plugin.meta.name} options/0`))
).toHaveLength(1);
expect(queryAllByLabelText(selectors.components.OptionsGroup.toggle(`${CATEGORY_NAME}/1`))).toHaveLength(0);
expect(queryAllByText(SUBCATEGORY_NAME, { exact: false })).toHaveLength(0);
});
});
});

View File

@ -1,131 +0,0 @@
import React, { useCallback, ReactNode } from 'react';
import { get, groupBy } from 'lodash';
import { Counter, Field, Label } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { updateDefaultFieldConfigValue } from './utils';
import { FieldConfigPropertyItem, FieldConfigSource, VariableSuggestionsScope } from '@grafana/data';
import { getDataLinksVariableSuggestions } from '../../../panel/panellinks/link_srv';
import { OptionsGroup } from './OptionsGroup';
import { Props } from './types';
export const DefaultFieldConfigEditor: React.FC<Props> = ({ data, onChange, config, plugin }) => {
const onDefaultValueChange = useCallback(
(name: string, value: any, isCustom: boolean | undefined) => {
onChange(updateDefaultFieldConfigValue(config, name, value, isCustom));
},
[config, onChange]
);
const renderEditor = useCallback(
(item: FieldConfigPropertyItem, categoryItemCount: number) => {
if (item.isCustom && item.showIf && !item.showIf(config.defaults.custom, data)) {
return null;
}
if (item.hideFromDefaults) {
return null;
}
const defaults = config.defaults;
const value = item.isCustom
? defaults.custom
? get(defaults.custom, item.path)
: undefined
: get(defaults, item.path);
let label: ReactNode | undefined = (
<Label description={item.description} category={item.category?.slice(1) as string[]}>
{item.name}
</Label>
);
// hide label if there is only one item and category name is same as item, name
if (categoryItemCount === 1 && item.category?.[0] === item.name) {
label = undefined;
}
return (
<Field
label={label}
key={`${item.id}/${item.isCustom}`}
aria-label={selectors.components.PanelEditor.FieldOptions.propertyEditor(
item.isCustom ? 'Custom' : 'Default'
)}
>
<item.editor
item={item}
value={value}
onChange={(v) => onDefaultValueChange(item.path, v, item.isCustom)}
context={{
data,
getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(data, scope),
}}
/>
</Field>
);
},
[config]
);
const GENERAL_OPTIONS_CATEGORY = `${plugin.meta.name} options`;
const groupedConfigs = groupBy(plugin.fieldConfigRegistry.list(), (i) => {
if (!i.category) {
return GENERAL_OPTIONS_CATEGORY;
}
return i.category[0] ? i.category[0] : GENERAL_OPTIONS_CATEGORY;
});
return (
<div aria-label={selectors.components.FieldConfigEditor.content}>
{Object.keys(groupedConfigs).map((groupName, i) => {
const group = groupedConfigs[groupName];
const groupItemsCounter = countGroupItems(group, config);
if (!shouldRenderGroup(group)) {
return undefined;
}
return (
<OptionsGroup
renderTitle={(isExpanded) => {
return (
<>
{groupName} {!isExpanded && groupItemsCounter && <Counter value={groupItemsCounter} />}
</>
);
}}
id={`${groupName}/${i}`}
key={`${groupName}/${i}`}
>
{group.map((c) => {
return renderEditor(c, group.length);
})}
</OptionsGroup>
);
})}
</div>
);
};
function countGroupItems(group: FieldConfigPropertyItem[], config: FieldConfigSource) {
let counter = 0;
for (const item of group) {
const value = item.isCustom
? config.defaults.custom
? config.defaults.custom[item.path]
: undefined
: (config.defaults as any)[item.path];
if (item.getItemsCount && item.getItemsCount(value) > 0) {
counter = counter + item.getItemsCount(value);
}
}
return counter === 0 ? undefined : counter;
}
function shouldRenderGroup(group: FieldConfigPropertyItem[]) {
const hiddenPropertiesCount = group.filter((i) => i.hideFromDefaults).length;
return group.length - hiddenPropertiesCount > 0;
}

View File

@ -1,8 +1,14 @@
import { DynamicConfigValue, FieldConfigOptionsRegistry, FieldOverrideContext, GrafanaTheme } from '@grafana/data';
import {
DynamicConfigValue,
FieldConfigOptionsRegistry,
FieldConfigProperty,
FieldOverrideContext,
GrafanaTheme,
} from '@grafana/data';
import React from 'react';
import { Counter, Field, HorizontalGroup, IconButton, Label, stylesFactory, useTheme } from '@grafana/ui';
import { css, cx } from 'emotion';
import { OptionsGroup } from './OptionsGroup';
import { OptionsPaneCategory } from './OptionsPaneCategory';
interface DynamicConfigValueEditorProps {
property: DynamicConfigValue;
@ -10,7 +16,6 @@ interface DynamicConfigValueEditorProps {
onChange: (value: DynamicConfigValue) => void;
context: FieldOverrideContext;
onRemove: () => void;
isCollapsible?: boolean;
isSystemOverride?: boolean;
}
@ -20,7 +25,6 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
registry,
onChange,
onRemove,
isCollapsible,
isSystemOverride,
}) => {
const theme = useTheme();
@ -30,15 +34,20 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
if (!item) {
return null;
}
const isCollapsible =
Array.isArray(property.value) ||
property.id === FieldConfigProperty.Thresholds ||
property.id === FieldConfigProperty.Links ||
property.id === FieldConfigProperty.Mappings;
const labelCategory = item.category?.filter((c) => c !== item.name);
let editor;
// eslint-disable-next-line react/display-name
const renderLabel = (includeDescription = true, includeCounter = false) => (isExpanded = false) => (
<HorizontalGroup justify="space-between">
<Label
category={item.category?.filter((c) => c !== undefined) as string[]}
description={includeDescription ? item.description : undefined}
>
<Label category={labelCategory} description={includeDescription ? item.description : undefined}>
{item.name}
{!isExpanded && includeCounter && item.getItemsCount && <Counter value={item.getItemsCount(property.value)} />}
</Label>
@ -52,15 +61,15 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
if (isCollapsible) {
editor = (
<OptionsGroup
<OptionsPaneCategory
id={item.name}
renderTitle={renderLabel(false, true)}
className={css`
padding-left: 0;
padding-right: 0;
`}
nested
defaultToClosed={property.value !== undefined}
isNested
isOpenDefault={property.value !== undefined}
>
<item.override
value={property.value}
@ -70,7 +79,7 @@ export const DynamicConfigValueEditor: React.FC<DynamicConfigValueEditorProps> =
item={item}
context={context}
/>
</OptionsGroup>
</OptionsPaneCategory>
);
} else {
editor = (

View File

@ -1,185 +0,0 @@
import React, { FC, memo, ReactNode, useCallback, useEffect, useState } from 'react';
import { css, cx } from 'emotion';
import _ from 'lodash';
import { GrafanaTheme } from '@grafana/data';
import { Icon, stylesFactory, useTheme } from '@grafana/ui';
import { PANEL_EDITOR_UI_STATE_STORAGE_KEY } from './state/reducers';
import { useLocalStorage } from 'react-use';
import { selectors } from '@grafana/e2e-selectors';
export interface OptionsGroupProps {
id: string;
title?: React.ReactNode;
renderTitle?: (isExpanded: boolean) => React.ReactNode;
defaultToClosed?: boolean;
className?: string;
nested?: boolean;
persistMe?: boolean;
onToggle?: (isExpanded: boolean) => void;
children: ((toggleExpand: (expanded: boolean) => void) => ReactNode) | ReactNode;
}
export const OptionsGroup: FC<OptionsGroupProps> = ({
id,
title,
children,
defaultToClosed,
renderTitle,
className,
nested = false,
persistMe = true,
onToggle,
}) => {
if (persistMe) {
return (
<CollapsibleSectionWithPersistence
id={id}
defaultToClosed={defaultToClosed}
className={className}
nested={nested}
renderTitle={renderTitle}
persistMe={persistMe}
title={title}
onToggle={onToggle}
>
{children}
</CollapsibleSectionWithPersistence>
);
}
return (
<CollapsibleSection
id={id}
defaultToClosed={defaultToClosed}
className={className}
nested={nested}
renderTitle={renderTitle}
title={title}
onToggle={onToggle}
>
{children}
</CollapsibleSection>
);
};
const CollapsibleSectionWithPersistence: FC<OptionsGroupProps> = memo((props) => {
const [value, setValue] = useLocalStorage(getOptionGroupStorageKey(props.id), {
defaultToClosed: props.defaultToClosed,
});
const onToggle = useCallback(
(isExpanded: boolean) => {
setValue({ defaultToClosed: !isExpanded });
if (props.onToggle) {
props.onToggle(isExpanded);
}
},
[setValue, props.onToggle]
);
return <CollapsibleSection {...props} defaultToClosed={value.defaultToClosed} onToggle={onToggle} />;
});
const CollapsibleSection: FC<Omit<OptionsGroupProps, 'persistMe'>> = ({
id,
title,
children,
defaultToClosed,
renderTitle,
className,
nested = false,
onToggle,
}) => {
const [isExpanded, toggleExpand] = useState(!defaultToClosed);
const theme = useTheme();
const styles = getStyles(theme, isExpanded, nested);
useEffect(() => {
if (onToggle) {
onToggle(isExpanded);
}
}, [isExpanded]);
return (
<div className={cx(styles.box, className, 'options-group')}>
<div
className={styles.header}
onClick={() => toggleExpand(!isExpanded)}
aria-label={selectors.components.OptionsGroup.toggle(id)}
>
<div className={cx(styles.toggle, 'editor-options-group-toggle')}>
<Icon name={isExpanded ? 'angle-down' : 'angle-right'} />
</div>
<div className={styles.title}>{renderTitle ? renderTitle(isExpanded) : title}</div>
</div>
{isExpanded && <div className={styles.body}>{_.isFunction(children) ? children(toggleExpand) : children}</div>}
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme, isExpanded: boolean, isNested: boolean) => {
return {
box: cx(
!isNested &&
css`
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
`,
isNested &&
isExpanded &&
css`
margin-bottom: ${theme.spacing.formSpacingBase * 2}px;
`
),
toggle: css`
color: ${theme.colors.textWeak};
margin-right: ${theme.spacing.sm};
`,
title: css`
flex-grow: 1;
overflow: hidden;
`,
header: cx(
css`
display: flex;
cursor: pointer;
align-items: baseline;
padding: ${theme.spacing.sm} ${theme.spacing.md} ${theme.spacing.sm} ${theme.spacing.sm};
color: ${isExpanded ? theme.colors.text : theme.colors.formLabel};
font-weight: ${theme.typography.weight.semibold};
&:hover {
color: ${theme.colors.text};
.editor-options-group-toggle {
color: ${theme.colors.text};
}
}
`,
isNested &&
css`
padding-left: 0;
padding-right: 0;
padding-top: 0;
`
),
body: cx(
css`
padding: ${theme.spacing.sm} ${theme.spacing.md} ${theme.spacing.sm} ${theme.spacing.xl};
`,
isNested &&
css`
position: relative;
padding-right: 0;
&:before {
content: '';
position: absolute;
top: 0;
left: 8px;
width: 1px;
height: 100%;
background: ${theme.colors.pageHeaderBorder};
}
`
),
};
});
const getOptionGroupStorageKey = (id: string): string => `${PANEL_EDITOR_UI_STATE_STORAGE_KEY}.optionGroup[${id}]`;

View File

@ -0,0 +1,102 @@
import React from 'react';
import { FieldConfigSource, GrafanaTheme, PanelPlugin } from '@grafana/data';
import { DashboardModel, PanelModel } from '../../state';
import { useStyles } from '@grafana/ui';
import { css } from 'emotion';
import { selectors } from '@grafana/e2e-selectors';
import { VisualizationButton } from './VisualizationButton';
import { OptionsPaneOptions } from './OptionsPaneOptions';
import { useSelector } from 'react-redux';
import { StoreState } from 'app/types';
import { VisualizationSelectPane } from './VisualizationSelectPane';
import { usePanelLatestData } from './usePanelLatestData';
interface Props {
plugin: PanelPlugin;
panel: PanelModel;
width: number;
dashboard: DashboardModel;
onFieldConfigsChange: (config: FieldConfigSource) => void;
onPanelOptionsChanged: (options: any) => void;
onPanelConfigChange: (configKey: string, value: any) => void;
}
export const OptionsPane: React.FC<Props> = ({
plugin,
panel,
width,
onFieldConfigsChange,
onPanelOptionsChanged,
onPanelConfigChange,
dashboard,
}: Props) => {
const styles = useStyles(getStyles);
const isVizPickerOpen = useSelector((state: StoreState) => state.panelEditor.isVizPickerOpen);
const { data } = usePanelLatestData(panel, { withTransforms: true, withFieldConfig: false });
return (
<div className={styles.wrapper} aria-label={selectors.components.PanelEditor.OptionsPane.content}>
{!isVizPickerOpen && (
<>
<div className={styles.vizButtonWrapper}>
<VisualizationButton panel={panel} />
</div>
<div className={styles.optionsWrapper}>
<OptionsPaneOptions
panel={panel}
dashboard={dashboard}
plugin={plugin}
data={data}
onFieldConfigsChange={onFieldConfigsChange}
onPanelOptionsChanged={onPanelOptionsChanged}
onPanelConfigChange={onPanelConfigChange}
/>
</div>
</>
)}
{isVizPickerOpen && <VisualizationSelectPane panel={panel} />}
</div>
);
};
const getStyles = (theme: GrafanaTheme) => {
return {
wrapper: css`
height: 100%;
width: 100%;
display: flex;
flex: 1 1 0;
flex-direction: column;
padding: ${theme.spacing.md} ${theme.spacing.sm} 0 0;
`,
optionsWrapper: css`
flex-grow: 1;
min-height: 0;
`,
vizButtonWrapper: css`
padding: 0 0 ${theme.spacing.md} 0;
`,
legacyOptions: css`
label: legacy-options;
.panel-options-grid {
display: flex;
flex-direction: column;
}
.panel-options-group {
margin-bottom: 0;
}
.panel-options-group__body {
padding: ${theme.spacing.md} 0;
}
.section {
display: block;
margin: ${theme.spacing.md} 0;
&:first-child {
margin-top: 0;
}
}
`,
};
};

View File

@ -0,0 +1,154 @@
import React, { FC, ReactNode, useCallback, useEffect, useState } from 'react';
import { css, cx } from 'emotion';
import _ from 'lodash';
import { GrafanaTheme } from '@grafana/data';
import { Counter, Icon, useStyles } from '@grafana/ui';
import { PANEL_EDITOR_UI_STATE_STORAGE_KEY } from './state/reducers';
import { useLocalStorage } from 'react-use';
import { selectors } from '@grafana/e2e-selectors';
export interface OptionsPaneCategoryProps {
id: string;
title?: string;
renderTitle?: (isExpanded: boolean) => React.ReactNode;
isOpenDefault?: boolean;
itemsCount?: number;
forceOpen?: number;
className?: string;
isNested?: boolean;
children: ReactNode;
}
export const OptionsPaneCategory: FC<OptionsPaneCategoryProps> = React.memo(
({ id, title, children, forceOpen, isOpenDefault, renderTitle, className, itemsCount, isNested = false }) => {
const [savedState, setSavedState] = useLocalStorage(getOptionGroupStorageKey(id), {
isExpanded: isOpenDefault !== false,
});
const [isExpanded, setIsExpanded] = useState(savedState.isExpanded);
const styles = useStyles(getStyles);
useEffect(() => {
if (!isExpanded && forceOpen && forceOpen > 0) {
setIsExpanded(true);
}
}, [forceOpen]);
const onToggle = useCallback(() => {
setSavedState({ isExpanded: !isExpanded });
setIsExpanded(!isExpanded);
}, [setSavedState, setIsExpanded, isExpanded]);
if (!renderTitle) {
renderTitle = function defaultTitle(isExpanded: boolean) {
if (isExpanded || itemsCount === undefined || itemsCount === 0) {
return title;
}
return (
<span>
{title} <Counter value={itemsCount} />
</span>
);
};
}
const boxStyles = cx(
{
[styles.box]: true,
[styles.boxExpanded]: isExpanded,
[styles.boxNestedExpanded]: isNested && isExpanded,
},
className,
'options-group'
);
const headerStyles = cx(styles.header, {
[styles.headerExpanded]: isExpanded,
[styles.headerNested]: isNested,
});
const bodyStyles = cx(styles.body, {
[styles.bodyNested]: isNested,
});
return (
<div className={boxStyles} data-testid="options-category">
<div className={headerStyles} onClick={onToggle} aria-label={selectors.components.OptionsGroup.toggle(id)}>
<div className={cx(styles.toggle, 'editor-options-group-toggle')}>
<Icon name={isExpanded ? 'angle-down' : 'angle-right'} />
</div>
<div className={styles.title} role="heading">
{renderTitle(isExpanded)}
</div>
</div>
{isExpanded && <div className={bodyStyles}>{children}</div>}
</div>
);
}
);
const getStyles = (theme: GrafanaTheme) => {
return {
box: css`
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
&:last-child {
border-bottom: none;
}
`,
boxExpanded: css`
border-bottom: 0;
`,
boxNestedExpanded: css`
margin-bottom: ${theme.spacing.formSpacingBase * 2}px;
`,
toggle: css`
color: ${theme.colors.textWeak};
margin-right: ${theme.spacing.sm};
`,
title: css`
flex-grow: 1;
overflow: hidden;
`,
header: css`
display: flex;
cursor: pointer;
align-items: baseline;
padding: ${theme.spacing.sm};
color: ${theme.colors.formLabel};
font-weight: ${theme.typography.weight.semibold};
&:hover {
color: ${theme.colors.text};
.editor-options-group-toggle {
color: ${theme.colors.text};
}
}
`,
headerExpanded: css`
color: ${theme.colors.text};
`,
headerNested: css`
padding-left: 0;
padding-right: 0;
`,
body: css`
padding: ${theme.spacing.sm} ${theme.spacing.md} ${theme.spacing.sm} ${theme.spacing.xl};
`,
bodyNested: css`
position: relative;
padding-right: 0;
&:before {
content: '';
position: absolute;
top: 0;
left: 8px;
width: 1px;
height: 100%;
background: ${theme.colors.pageHeaderBorder};
}
`,
};
};
const getOptionGroupStorageKey = (id: string): string => `${PANEL_EDITOR_UI_STATE_STORAGE_KEY}.optionGroup[${id}]`;

View File

@ -0,0 +1,52 @@
import React from 'react';
import { OptionsPaneCategory } from './OptionsPaneCategory';
import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor';
export interface OptionsPaneCategoryDescriptorProps {
id: string;
title: string;
renderTitle?: (isExpanded: boolean) => React.ReactNode;
isOpenDefault?: boolean;
forceOpen?: number;
className?: string;
isNested?: boolean;
itemsCount?: number;
customRender?: () => React.ReactNode;
}
/**
* This is not a real React component but an intermediary to enable deep option search without traversing a React node tree.
*/
export class OptionsPaneCategoryDescriptor {
items: OptionsPaneItemDescriptor[] = [];
categories: OptionsPaneCategoryDescriptor[] = [];
parent?: OptionsPaneCategoryDescriptor;
constructor(public props: OptionsPaneCategoryDescriptorProps) {}
addItem(item: OptionsPaneItemDescriptor) {
item.parent = this;
this.items.push(item);
return this;
}
addCategory(category: OptionsPaneCategoryDescriptor) {
category.props.isNested = true;
category.parent = this;
this.categories.push(category);
return this;
}
render(isSearching?: boolean) {
if (this.props.customRender) {
return this.props.customRender();
}
return (
<OptionsPaneCategory key={this.props.title} {...this.props}>
{this.items.map((item) => item.render())}
{this.categories.map((category) => category.render())}
</OptionsPaneCategory>
);
}
}

View File

@ -1,312 +0,0 @@
import React, { CSSProperties, useCallback, useState } from 'react';
import Transition from 'react-transition-group/Transition';
import { FieldConfigSource, GrafanaTheme, PanelPlugin, SelectableValue } from '@grafana/data';
import { DashboardModel, PanelModel } from '../../state';
import {
CustomScrollbar,
Icon,
Input,
Select,
stylesFactory,
Tab,
TabContent,
TabsBar,
ToolbarButton,
useTheme,
} from '@grafana/ui';
import { OverrideFieldConfigEditor } from './OverrideFieldConfigEditor';
import { DefaultFieldConfigEditor } from './DefaultFieldConfigEditor';
import { css } from 'emotion';
import { PanelOptionsTab } from './PanelOptionsTab';
import { usePanelLatestData } from './usePanelLatestData';
import { selectors } from '@grafana/e2e-selectors';
interface Props {
plugin: PanelPlugin;
panel: PanelModel;
width: number;
dashboard: DashboardModel;
onClose: () => void;
onFieldConfigsChange: (config: FieldConfigSource) => void;
onPanelOptionsChanged: (options: any) => void;
onPanelConfigChange: (configKey: string, value: any) => void;
}
export const OptionsPaneContent: React.FC<Props> = ({
plugin,
panel,
width,
onFieldConfigsChange,
onPanelOptionsChanged,
onPanelConfigChange,
onClose,
dashboard,
}: Props) => {
const theme = useTheme();
const styles = getStyles(theme);
const [activeTab, setActiveTab] = useState('options');
const [isSearching, setSearchMode] = useState(false);
const { data, hasSeries } = usePanelLatestData(panel, { withTransforms: true, withFieldConfig: false });
const renderFieldOptions = useCallback(
(plugin: PanelPlugin) => {
const fieldConfig = panel.getFieldConfig();
if (!fieldConfig || !hasSeries) {
return null;
}
return (
<DefaultFieldConfigEditor
config={fieldConfig}
plugin={plugin}
onChange={onFieldConfigsChange}
/* hasSeries makes sure current data is there */
data={data!.series}
/>
);
},
[data, plugin, panel, onFieldConfigsChange]
);
const renderFieldOverrideOptions = useCallback(
(plugin: PanelPlugin) => {
const fieldConfig = panel.getFieldConfig();
if (!fieldConfig || !hasSeries) {
return null;
}
return (
<OverrideFieldConfigEditor
config={fieldConfig}
plugin={plugin}
onChange={onFieldConfigsChange}
/* hasSeries makes sure current data is there */
data={data!.series}
/>
);
},
[data, plugin, panel, onFieldConfigsChange]
);
// When the panel has no query only show the main tab
const showMainTab = activeTab === 'options' || plugin.meta.skipDataQuery;
return (
<div className={styles.panelOptionsPane} aria-label={selectors.components.PanelEditor.OptionsPane.content}>
{plugin && (
<div className={styles.wrapper}>
<TabsBar className={styles.tabsBar}>
<TabsBarContent
width={width}
plugin={plugin}
isSearching={isSearching}
styles={styles}
activeTab={activeTab}
onClose={onClose}
setSearchMode={setSearchMode}
setActiveTab={setActiveTab}
panel={panel}
/>
</TabsBar>
<TabContent className={styles.tabContent}>
<CustomScrollbar autoHeightMin="100%">
{showMainTab ? (
<PanelOptionsTab
panel={panel}
plugin={plugin}
dashboard={dashboard}
data={data}
onPanelConfigChange={onPanelConfigChange}
onPanelOptionsChanged={onPanelOptionsChanged}
/>
) : (
<>
{activeTab === 'defaults' && renderFieldOptions(plugin)}
{activeTab === 'overrides' && renderFieldOverrideOptions(plugin)}
</>
)}
</CustomScrollbar>
</TabContent>
</div>
)}
</div>
);
};
export const TabsBarContent: React.FC<{
width: number;
plugin: PanelPlugin;
isSearching: boolean;
activeTab: string;
styles: OptionsPaneStyles;
onClose: () => void;
setSearchMode: (mode: boolean) => void;
setActiveTab: (tab: string) => void;
panel: PanelModel;
}> = ({ width, plugin, isSearching, activeTab, onClose, setSearchMode, setActiveTab, styles, panel }) => {
const overridesCount =
panel.getFieldConfig().overrides.length === 0 ? undefined : panel.getFieldConfig().overrides.length;
if (isSearching) {
const defaultStyles = {
transition: 'width 50ms ease-in-out',
width: '50%',
display: 'flex',
};
const transitionStyles: { [str: string]: CSSProperties } = {
entered: { width: '100%' },
};
return (
<Transition in={true} timeout={0} appear={true}>
{(state) => {
return (
<div className={styles.searchWrapper}>
<div style={{ ...defaultStyles, ...transitionStyles[state] }}>
<Input
className={styles.searchInput}
type="text"
prefix={<Icon name="search" />}
ref={(elem) => elem && elem.focus()}
placeholder="Search all options"
suffix={
<Icon name="times" onClick={() => setSearchMode(false)} className={styles.searchRemoveIcon} />
}
/>
</div>
</div>
);
}}
</Transition>
);
}
// Show the appropriate tabs
let tabs = tabSelections;
let active = tabs.find((v) => v.value === activeTab)!;
// If no field configs hide Fields & Override tab
if (plugin.fieldConfigRegistry.isEmpty()) {
active = tabSelections[0];
tabs = [active];
}
return (
<>
{width < 352 ? (
<div className="flex-grow-1" aria-label={selectors.components.PanelEditor.OptionsPane.select}>
<Select
options={tabs}
value={active}
onChange={(v) => {
setActiveTab(v.value!);
}}
/>
</div>
) : (
<>
{tabs.map((item) => (
<Tab
key={item.value}
label={item.label!}
counter={item.value === 'overrides' ? overridesCount : undefined}
active={active.value === item.value}
onChangeTab={() => setActiveTab(item.value!)}
title={item.tooltip}
aria-label={selectors.components.PanelEditor.OptionsPane.tab(item.label!)}
/>
))}
<div className="flex-grow-1" />
</>
)}
<div className={styles.tabsButton}>
<ToolbarButton icon="angle-right" tooltip="Close options pane" onClick={onClose} />
</div>
</>
);
};
const tabSelections: Array<SelectableValue<string>> = [
{
label: 'Panel',
value: 'options',
tooltip: 'Configure panel display options',
},
{
label: 'Field',
value: 'defaults',
tooltip: 'Configure field options',
},
{
label: 'Overrides',
value: 'overrides',
tooltip: 'Configure field option overrides',
},
];
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
wrapper: css`
display: flex;
flex-direction: column;
height: 100%;
padding-top: ${theme.spacing.md};
`,
panelOptionsPane: css`
height: 100%;
width: 100%;
`,
tabsBar: css`
padding-right: ${theme.spacing.sm};
`,
searchWrapper: css`
display: flex;
flex-grow: 1;
flex-direction: row-reverse;
`,
searchInput: css`
color: ${theme.colors.textWeak};
flex-grow: 1;
`,
searchRemoveIcon: css`
cursor: pointer;
`,
tabContent: css`
padding: 0;
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 0;
background: ${theme.colors.bodyBg};
border-left: 1px solid ${theme.colors.pageHeaderBorder};
`,
tabsButton: css``,
legacyOptions: css`
label: legacy-options;
.panel-options-grid {
display: flex;
flex-direction: column;
}
.panel-options-group {
margin-bottom: 0;
}
.panel-options-group__body {
padding: ${theme.spacing.md} 0;
}
.section {
display: block;
margin: ${theme.spacing.md} 0;
&:first-child {
margin-top: 0;
}
}
`,
};
});
type OptionsPaneStyles = ReturnType<typeof getStyles>;

View File

@ -0,0 +1,76 @@
import { selectors } from '@grafana/e2e-selectors';
import { Field, Label } from '@grafana/ui';
import React, { ComponentType, ReactNode } from 'react';
import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor';
export interface OptionsPaneItemProps {
title: string;
value?: any;
description?: string;
popularRank?: number;
Component: ComponentType;
skipField?: boolean;
showIf?: () => boolean;
}
/**
* This is not a real React component but an intermediary to enable deep option search without traversing a React node tree.
*/
export class OptionsPaneItemDescriptor {
parent!: OptionsPaneCategoryDescriptor;
constructor(public props: OptionsPaneItemProps) {}
getLabel(isSearching?: boolean): ReactNode {
const { title, description } = this.props;
if (!isSearching) {
// Do not render label for categories with only one child
if (this.parent.props.title === title) {
return null;
}
return title;
}
const categories: string[] = [];
if (this.parent.parent) {
categories.push(this.parent.parent.props.title);
}
if (this.parent.props.title !== title) {
categories.push(this.parent.props.title);
}
return (
<Label description={description} category={categories}>
{title}
</Label>
);
}
render(isSearching?: boolean) {
const { title, description, Component, showIf, skipField } = this.props;
const key = `${this.parent.props.id} ${title}`;
if (showIf && !showIf()) {
return null;
}
if (skipField) {
return <Component key={key} />;
}
return (
<Field
label={this.getLabel(isSearching)}
description={description}
key={key}
aria-label={selectors.components.PanelEditor.OptionsPane.fieldLabel(key)}
>
<Component />
</Field>
);
}
}

View File

@ -0,0 +1,199 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import {
FieldConfigSource,
LoadingState,
PanelData,
standardEditorsRegistry,
standardFieldConfigEditorRegistry,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { OptionsPaneOptions } from './OptionsPaneOptions';
import { DashboardModel, PanelModel } from '../../state';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks';
import { getStandardFieldConfigs, getStandardOptionEditors } from '@grafana/ui';
standardEditorsRegistry.setInit(getStandardOptionEditors);
standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs);
const mockStore = configureMockStore<any, any>();
const OptionsPaneSelector = selectors.components.PanelEditor.OptionsPane;
class OptionsPaneOptionsTestScenario {
onFieldConfigsChange = jest.fn();
onPanelOptionsChanged = jest.fn();
onPanelConfigChange = jest.fn();
panelData: PanelData = {
series: [],
state: LoadingState.Done,
timeRange: {} as any,
};
plugin = getPanelPlugin({
id: 'TestPanel',
}).useFieldConfig({
standardOptions: {},
useCustomConfig: (b) => {
b.addBooleanSwitch({
name: 'CustomBool',
path: 'CustomBool',
})
.addBooleanSwitch({
name: 'HiddenFromDef',
path: 'HiddenFromDef',
hideFromDefaults: true,
})
.addTextInput({
name: 'TextPropWithCategory',
path: 'TextPropWithCategory',
settings: {
placeholder: 'CustomTextPropPlaceholder',
},
category: ['Axis'],
});
},
});
panel = new PanelModel({
title: 'Test title',
type: this.plugin.meta.id,
fieldConfig: {
defaults: {
max: 100,
thresholds: {
mode: 'absolute',
steps: [
{ value: -Infinity, color: 'green' },
{ value: 100, color: 'green' },
],
},
},
overrides: [],
},
options: {},
});
dashboard = new DashboardModel({});
store = mockStore({
dashboard: { panels: [] },
templating: {
variables: {},
},
});
render() {
render(
<Provider store={this.store}>
<OptionsPaneOptions
data={this.panelData}
plugin={this.plugin}
panel={this.panel}
dashboard={this.dashboard}
onFieldConfigsChange={this.onFieldConfigsChange}
onPanelConfigChange={this.onPanelConfigChange}
onPanelOptionsChanged={this.onPanelOptionsChanged}
/>
</Provider>
);
}
}
describe('OptionsPaneOptions', () => {
it('should render panel frame options', async () => {
const scenario = new OptionsPaneOptionsTestScenario();
scenario.render();
expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument();
});
it('should render all categories', async () => {
const scenario = new OptionsPaneOptionsTestScenario();
scenario.render();
expect(screen.getByRole('heading', { name: /Panel options/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Standard options/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Thresholds/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /TestPanel/ })).toBeInTheDocument();
});
it('should render custom options', () => {
const scenario = new OptionsPaneOptionsTestScenario();
scenario.render();
expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('TestPanel CustomBool'))).toBeInTheDocument();
});
it('should not render options that are marked as hidden from defaults', () => {
const scenario = new OptionsPaneOptionsTestScenario();
scenario.render();
expect(screen.queryByLabelText(OptionsPaneSelector.fieldLabel('TestPanel HiddenFromDef'))).not.toBeInTheDocument();
});
it('should create categories for field options with category', () => {
const scenario = new OptionsPaneOptionsTestScenario();
scenario.render();
expect(screen.getByRole('heading', { name: /Axis/ })).toBeInTheDocument();
});
it('should not render categories with hidden fields only', () => {
const scenario = new OptionsPaneOptionsTestScenario();
scenario.plugin = getPanelPlugin({
id: 'TestPanel',
}).useFieldConfig({
standardOptions: {},
useCustomConfig: (b) => {
b.addBooleanSwitch({
name: 'CustomBool',
path: 'CustomBool',
hideFromDefaults: true,
category: ['Axis'],
});
},
});
scenario.render();
expect(screen.queryByRole('heading', { name: /Axis/ })).not.toBeInTheDocument();
});
it('should call onPanelConfigChange when updating title', () => {
const scenario = new OptionsPaneOptionsTestScenario();
scenario.render();
const input = screen.getByDisplayValue(scenario.panel.title);
fireEvent.change(input, { target: { value: 'New' } });
fireEvent.blur(input);
expect(scenario.onPanelConfigChange).toHaveBeenCalledWith('title', 'New');
});
it('should call onFieldConfigsChange when updating field config', () => {
const scenario = new OptionsPaneOptionsTestScenario();
scenario.render();
const input = screen.getByPlaceholderText('CustomTextPropPlaceholder');
fireEvent.change(input, { target: { value: 'New' } });
fireEvent.blur(input);
const newFieldConfig: FieldConfigSource = scenario.panel.fieldConfig;
newFieldConfig.defaults.custom = { TextPropWithCategory: 'New' };
expect(scenario.onFieldConfigsChange).toHaveBeenCalledWith(newFieldConfig);
});
it('should only render hits when search query specified', async () => {
const scenario = new OptionsPaneOptionsTestScenario();
scenario.render();
const input = screen.getByPlaceholderText('Search options');
fireEvent.change(input, { target: { value: 'TextPropWithCategory' } });
fireEvent.blur(input);
expect(screen.queryByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).not.toBeInTheDocument();
expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Axis TextPropWithCategory'))).toBeInTheDocument();
});
});

View File

@ -0,0 +1,192 @@
import React, { useMemo, useState } from 'react';
import { FieldConfigSource, GrafanaTheme, PanelData, PanelPlugin, SelectableValue } from '@grafana/data';
import { DashboardModel, PanelModel } from '../../state';
import { CustomScrollbar, RadioButtonGroup, useStyles } from '@grafana/ui';
import { getPanelFrameCategory } from './getPanelFrameOptions';
import { getVizualizationOptions } from './getVizualizationOptions';
import { css } from 'emotion';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { OptionsPaneCategory } from './OptionsPaneCategory';
import { getFieldOverrideCategories } from './getFieldOverrideElements';
import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor';
import { OptionSearchEngine } from './state/OptionSearchEngine';
import { AngularPanelOptions } from './AngularPanelOptions';
import { getRecentOptions } from './state/getRecentOptions';
interface Props {
plugin: PanelPlugin;
panel: PanelModel;
dashboard: DashboardModel;
data?: PanelData;
onFieldConfigsChange: (config: FieldConfigSource) => void;
onPanelOptionsChanged: (options: any) => void;
onPanelConfigChange: (configKey: string, value: any) => void;
}
export const OptionsPaneOptions: React.FC<Props> = (props) => {
const { plugin, dashboard, panel } = props;
const [searchQuery, setSearchQuery] = useState('');
const [listMode, setListMode] = useState(OptionFilter.All);
const styles = useStyles(getStyles);
const [panelFrameOptions, vizOptions, justOverrides] = useMemo(
() => [getPanelFrameCategory(props), getVizualizationOptions(props), getFieldOverrideCategories(props)],
[props]
);
const mainBoxElements: React.ReactNode[] = [];
const isSearching = searchQuery.length > 0;
const optionRadioFilters = useMemo(getOptionRadioFilters, []);
const allOptions = [panelFrameOptions, ...vizOptions];
if (isSearching) {
mainBoxElements.push(renderSearchHits(allOptions, justOverrides, searchQuery));
// If searching for angular panel then we need to add notice that results are limited
if (props.plugin.angularPanelCtrl) {
mainBoxElements.push(
<div className={styles.searchNotice} key="Search notice">
This is an old visualization type that does not support searching all options.
</div>
);
}
} else {
switch (listMode) {
case OptionFilter.All:
// Panel frame options first
mainBoxElements.push(panelFrameOptions.render());
// If angular add those options next
if (props.plugin.angularPanelCtrl) {
mainBoxElements.push(
<AngularPanelOptions plugin={plugin} dashboard={dashboard} panel={panel} key="AngularOptions" />
);
}
// Then add all panel & field defaults
for (const item of vizOptions) {
mainBoxElements.push(item.render());
}
break;
case OptionFilter.Overrides:
for (const override of justOverrides) {
mainBoxElements.push(override.render());
}
break;
case OptionFilter.Recent:
mainBoxElements.push(
<OptionsPaneCategory id="Recent options" title="Recent options" key="Recent options" forceOpen={1}>
{getRecentOptions(allOptions).map((item) => item.render())}
</OptionsPaneCategory>
);
break;
}
}
return (
<div className={styles.wrapper}>
<div className={styles.formBox}>
<div className={styles.formRow}>
<FilterInput width={0} value={searchQuery} onChange={setSearchQuery} placeholder={'Search options'} />
</div>
{!isSearching && (
<div className={styles.formRow}>
<RadioButtonGroup options={optionRadioFilters} value={listMode} fullWidth onChange={setListMode} />
</div>
)}
</div>
<div className={styles.scrollWrapper}>
<CustomScrollbar autoHeightMin="100%">
<div className={styles.mainBox}>{mainBoxElements}</div>
{!isSearching && listMode === OptionFilter.All && (
<div className={styles.overridesBox}>{justOverrides.map((override) => override.render())}</div>
)}
</CustomScrollbar>
</div>
</div>
);
};
function getOptionRadioFilters(): Array<SelectableValue<OptionFilter>> {
return [
{ label: OptionFilter.All, value: OptionFilter.All },
{ label: OptionFilter.Recent, value: OptionFilter.Recent },
{ label: OptionFilter.Overrides, value: OptionFilter.Overrides },
];
}
export enum OptionFilter {
All = 'All',
Overrides = 'Overrides',
Recent = 'Recent',
}
function renderSearchHits(
allOptions: OptionsPaneCategoryDescriptor[],
overrides: OptionsPaneCategoryDescriptor[],
searchQuery: string
) {
const engine = new OptionSearchEngine(allOptions, overrides);
const { optionHits, totalCount, overrideHits } = engine.search(searchQuery);
return (
<div key="search results">
<OptionsPaneCategory
id="Found options"
title={`Matched ${optionHits.length}/${totalCount} options`}
key="Normal options"
forceOpen={1}
>
{optionHits.map((hit) => hit.render(true))}
</OptionsPaneCategory>
{overrideHits.map((override) => override.render(true))}
</div>
);
}
const getStyles = (theme: GrafanaTheme) => ({
wrapper: css`
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 0;
`,
searchBox: css`
display: flex;
flex-direction: column;
min-height: 0;
`,
formRow: css`
margin-bottom: ${theme.spacing.sm};
`,
formBox: css`
padding: ${theme.spacing.sm};
background: ${theme.colors.bg1};
border: 1px solid ${theme.colors.border1};
border-bottom: none;
`,
closeButton: css`
margin-left: ${theme.spacing.sm};
`,
searchHits: css`
padding: ${theme.spacing.sm} ${theme.spacing.sm} 0 ${theme.spacing.sm};
`,
scrollWrapper: css`
flex-grow: 1;
min-height: 0;
`,
searchNotice: css`
font-size: ${theme.typography.size.sm};
color: ${theme.colors.textWeak};
padding: ${theme.spacing.sm};
text-align: center;
`,
mainBox: css`
background: ${theme.colors.bg1};
margin-bottom: ${theme.spacing.md};
border: 1px solid ${theme.colors.border1};
border-top: none;
`,
overridesBox: css`
background: ${theme.colors.bg1};
border: 1px solid ${theme.colors.border1};
margin-bottom: ${theme.spacing.md};
`,
});

View File

@ -0,0 +1,68 @@
import React, { FC } from 'react';
import { FieldConfigOptionsRegistry, GrafanaTheme, ConfigOverrideRule } from '@grafana/data';
import { HorizontalGroup, Icon, IconButton, useStyles } from '@grafana/ui';
import { FieldMatcherUIRegistryItem } from '@grafana/ui/src/components/MatchersUI/types';
import { css } from 'emotion';
interface OverrideCategoryTitleProps {
isExpanded: boolean;
registry: FieldConfigOptionsRegistry;
matcherUi: FieldMatcherUIRegistryItem<any>;
override: ConfigOverrideRule;
overrideName: string;
onOverrideRemove: () => void;
}
export const OverrideCategoryTitle: FC<OverrideCategoryTitleProps> = ({
isExpanded,
registry,
matcherUi,
overrideName,
override,
onOverrideRemove,
}) => {
const styles = useStyles(getStyles);
const properties = override.properties.map((p) => registry.getIfExists(p.id)).filter((prop) => !!prop);
const propertyNames = properties.map((p) => p?.name).join(', ');
const matcherOptions = matcherUi.optionsToLabel(override.matcher.options);
return (
<div>
<HorizontalGroup justify="space-between">
<div>{overrideName}</div>
{isExpanded && <IconButton name="trash-alt" onClick={onOverrideRemove} title="Remove override" />}
</HorizontalGroup>
{!isExpanded && (
<div className={styles.overrideDetails}>
<div className={styles.options} title={matcherOptions}>
{matcherOptions} <Icon name="angle-right" /> {propertyNames}
</div>
</div>
)}
</div>
);
};
OverrideCategoryTitle.displayName = 'OverrideTitle';
const getStyles = (theme: GrafanaTheme) => {
return {
matcherUi: css`
padding: ${theme.spacing.sm};
`,
propertyPickerWrapper: css`
margin-top: ${theme.spacing.formSpacingBase * 2}px;
`,
overrideDetails: css`
font-size: ${theme.typography.size.sm};
color: ${theme.colors.textWeak};
font-weight: ${theme.typography.weight.regular};
`,
options: css`
overflow: hidden;
padding-right: ${theme.spacing.xl};
`,
unknownLabel: css`
margin-bottom: 0;
`,
};
};

View File

@ -1,136 +0,0 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { FieldConfigOptionsRegistry } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { OverrideEditor } from './OverrideEditor';
describe('OverrideEditor', () => {
let registry: FieldConfigOptionsRegistry;
beforeEach(() => {
registry = new FieldConfigOptionsRegistry(() => {
return [
{
id: 'lineColor',
name: 'Line color',
path: 'lineColor',
isCustom: true,
shouldApply: () => true,
process: () => null,
override: () => null,
editor: () => null,
},
{
id: 'lineWidth',
name: 'Line width',
path: 'lineWidth',
isCustom: true,
shouldApply: () => true,
process: () => null,
override: () => null,
editor: () => null,
},
];
});
});
it('allow override option selection', () => {
const { queryAllByLabelText, getByLabelText } = render(
<OverrideEditor
name={'test'}
data={[]}
override={{
matcher: {
id: 'byName',
options: 'A-series',
},
properties: [],
}}
registry={registry}
onChange={() => {}}
onRemove={() => {}}
/>
);
fireEvent.click(getByLabelText(selectors.components.ValuePicker.button));
const selectOptions = queryAllByLabelText(selectors.components.Select.option);
expect(selectOptions).toHaveLength(2);
});
it('should be able to handle non registered properties without throwing exceptions', () => {
registry.register({
id: 'lineStyle',
name: 'Line style',
path: 'lineStyle',
isCustom: true,
shouldApply: () => true,
process: () => null,
override: () => null,
editor: () => null,
hideFromOverrides: true,
});
render(
<OverrideEditor
name={'test'}
data={[]}
override={{
matcher: {
id: 'byName',
options: 'A-series',
},
properties: [
{
id: 'lineStyle',
value: 'customValue',
},
{
id: 'does.not.exist',
value: 'testing',
},
],
}}
registry={registry}
onChange={() => {}}
onRemove={() => {}}
/>
);
});
it('should not allow override selection that marked as hidden from overrides', () => {
registry.register({
id: 'lineStyle',
name: 'Line style',
path: 'lineStyle',
isCustom: true,
shouldApply: () => true,
process: () => null,
override: () => null,
editor: () => null,
hideFromOverrides: true,
});
const { queryAllByLabelText, getByLabelText } = render(
<OverrideEditor
name={'test'}
data={[]}
override={{
matcher: {
id: 'byName',
options: 'A-series',
},
properties: [],
}}
registry={registry}
onChange={() => {}}
onRemove={() => {}}
/>
);
fireEvent.click(getByLabelText(selectors.components.ValuePicker.button));
const selectOptions = queryAllByLabelText(selectors.components.Select.option);
expect(selectOptions).toHaveLength(2);
});
});

View File

@ -1,211 +0,0 @@
import React, { useCallback } from 'react';
import {
ConfigOverrideRule,
DataFrame,
DynamicConfigValue,
FieldConfigOptionsRegistry,
FieldConfigProperty,
GrafanaTheme,
isSystemOverride as isSystemOverrideGuard,
VariableSuggestionsScope,
} from '@grafana/data';
import { Field, fieldMatchersUI, HorizontalGroup, Icon, IconButton, Label, useStyles, ValuePicker } from '@grafana/ui';
import { DynamicConfigValueEditor } from './DynamicConfigValueEditor';
import { getDataLinksVariableSuggestions } from '../../../panel/panellinks/link_srv';
import { css } from 'emotion';
import { OptionsGroup } from './OptionsGroup';
interface OverrideEditorProps {
name: string;
data: DataFrame[];
override: ConfigOverrideRule;
onChange: (config: ConfigOverrideRule) => void;
onRemove: () => void;
registry: FieldConfigOptionsRegistry;
}
const COLLECTION_STANDARD_PROPERTIES = [
FieldConfigProperty.Thresholds,
FieldConfigProperty.Links,
FieldConfigProperty.Mappings,
];
export const OverrideEditor: React.FC<OverrideEditorProps> = ({
name,
data,
override,
onChange,
onRemove,
registry,
}) => {
const matcherUi = fieldMatchersUI.get(override.matcher.id);
const styles = useStyles(getStyles);
const properties = override.properties.map((p) => registry.getIfExists(p.id)).filter((prop) => !!prop);
const matcherLabel = <Label>{matcherUi.name}</Label>;
const onMatcherConfigChange = useCallback(
(matcherConfig: any) => {
override.matcher.options = matcherConfig;
onChange(override);
},
[override, onChange]
);
const onDynamicConfigValueChange = useCallback(
(index: number, value: DynamicConfigValue) => {
override.properties[index].value = value;
onChange(override);
},
[override, onChange]
);
const onDynamicConfigValueRemove = useCallback(
(index: number) => {
override.properties.splice(index, 1);
onChange(override);
},
[override, onChange]
);
const onDynamicConfigValueAdd = useCallback(
(id: string) => {
const registryItem = registry.get(id);
const propertyConfig: DynamicConfigValue = {
id,
value: registryItem.defaultValue,
};
if (override.properties) {
override.properties.push(propertyConfig);
} else {
override.properties = [propertyConfig];
}
onChange(override);
},
[override, onChange]
);
let configPropertiesOptions = registry
.list()
.filter((o) => !o.hideFromOverrides)
.map((item) => {
let label = item.name;
if (item.category && item.category.length > 1) {
label = [...item.category!.slice(1), item.name].join(' > ');
}
return {
label,
value: item.id,
description: item.description,
};
});
const renderOverrideTitle = (isExpanded: boolean) => {
const propertyNames = properties.map((p) => p?.name).join(', ');
const matcherOptions = matcherUi.optionsToLabel(override.matcher.options);
return (
<div>
<HorizontalGroup justify="space-between">
<div>{name}</div>
<IconButton name="trash-alt" onClick={onRemove} />
</HorizontalGroup>
{!isExpanded && (
<div className={styles.overrideDetails}>
<div className={styles.options} title={matcherOptions}>
{matcherUi.name} <Icon name="angle-right" /> {matcherOptions}
</div>
<div className={styles.options} title={propertyNames}>
Properties overridden <Icon name="angle-right" />
{propertyNames}
</div>
</div>
)}
</div>
);
};
const isSystemOverride = isSystemOverrideGuard(override);
return (
<OptionsGroup renderTitle={renderOverrideTitle} id={name} key={name}>
<Field label={matcherLabel}>
<matcherUi.component
matcher={matcherUi.matcher}
data={data}
options={override.matcher.options}
onChange={(option) => onMatcherConfigChange(option)}
/>
</Field>
<>
{override.properties.map((p, j) => {
const item = registry.getIfExists(p.id);
if (!item) {
return null;
}
const isCollapsible =
Array.isArray(p.value) || COLLECTION_STANDARD_PROPERTIES.includes(p.id as FieldConfigProperty);
return (
<DynamicConfigValueEditor
key={`${p.id}/${j}`}
isCollapsible={isCollapsible}
isSystemOverride={isSystemOverride}
onChange={(value) => onDynamicConfigValueChange(j, value)}
onRemove={() => onDynamicConfigValueRemove(j)}
property={p}
registry={registry}
context={{
data,
getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(data, scope),
}}
/>
);
})}
{!isSystemOverride && override.matcher.options && (
<div className={styles.propertyPickerWrapper}>
<ValuePicker
label="Add override property"
variant="secondary"
icon="plus"
options={configPropertiesOptions}
onChange={(o) => {
onDynamicConfigValueAdd(o.value!);
}}
isFullWidth={false}
/>
</div>
)}
</>
</OptionsGroup>
);
};
const getStyles = (theme: GrafanaTheme) => {
return {
matcherUi: css`
padding: ${theme.spacing.sm};
`,
propertyPickerWrapper: css`
margin-top: ${theme.spacing.formSpacingBase * 2}px;
`,
overrideDetails: css`
font-size: ${theme.typography.size.sm};
color: ${theme.colors.textWeak};
font-weight: ${theme.typography.weight.regular};
`,
options: css`
overflow: hidden;
padding-right: ${theme.spacing.xl};
`,
unknownLabel: css`
margin-bottom: 0;
`,
};
};

View File

@ -1,112 +0,0 @@
import React from 'react';
import { cloneDeep } from 'lodash';
import { DocsId, SelectableValue } from '@grafana/data';
import { Container, FeatureInfoBox, fieldMatchersUI, useTheme, ValuePicker } from '@grafana/ui';
import { OverrideEditor } from './OverrideEditor';
import { selectors } from '@grafana/e2e-selectors';
import { css } from 'emotion';
import { getDocsLink } from 'app/core/utils/docsLinks';
import { Props } from './types';
/**
* Expects the container div to have size set and will fill it 100%
*/
export const OverrideFieldConfigEditor: React.FC<Props> = (props) => {
const theme = useTheme();
const { config } = props;
const onOverrideChange = (index: number, override: any) => {
const { config } = props;
let overrides = cloneDeep(config.overrides);
overrides[index] = override;
props.onChange({ ...config, overrides });
};
const onOverrideRemove = (overrideIndex: number) => {
const { config } = props;
let overrides = cloneDeep(config.overrides);
overrides.splice(overrideIndex, 1);
props.onChange({ ...config, overrides });
};
const onOverrideAdd = (value: SelectableValue<string>) => {
const { onChange, config } = props;
onChange({
...config,
overrides: [
...config.overrides,
{
matcher: {
id: value.value!,
},
properties: [],
},
],
});
};
const renderOverrides = () => {
const { config, data, plugin } = props;
const { fieldConfigRegistry } = plugin;
if (config.overrides.length === 0) {
return null;
}
return (
<div>
{config.overrides.map((o, i) => {
// TODO: apply matcher to retrieve fields
return (
<OverrideEditor
name={`Override ${i + 1}`}
key={`${o.matcher.id}/${i}`}
data={data}
override={o}
onChange={(value) => onOverrideChange(i, value)}
onRemove={() => onOverrideRemove(i)}
registry={fieldConfigRegistry}
/>
);
})}
</div>
);
};
const renderAddOverride = () => {
return (
<Container padding="md">
<ValuePicker
icon="plus"
label="Add an override for"
variant="secondary"
options={fieldMatchersUI
.list()
.filter((o) => !o.excludeFromPicker)
.map<SelectableValue<string>>((i) => ({ label: i.name, value: i.id, description: i.description }))}
onChange={(value) => onOverrideAdd(value)}
isFullWidth={false}
/>
</Container>
);
};
return (
<div aria-label={selectors.components.OverridesConfigEditor.content}>
{config.overrides.length === 0 && (
<FeatureInfoBox
title="Overrides"
url={getDocsLink(DocsId.FieldConfigOverrides)}
className={css`
margin: ${theme.spacing.md};
`}
>
Field override rules give you a fine grained control over how your data is displayed.
</FeatureInfoBox>
)}
{renderOverrides()}
{renderAddOverride()}
</div>
);
};

View File

@ -21,7 +21,7 @@ import { calculatePanelSize } from './utils';
import { PanelEditorTabs } from './PanelEditorTabs';
import { DashNavTimeControls } from '../DashNav/DashNavTimeControls';
import { OptionsPaneContent } from './OptionsPaneContent';
import { OptionsPane } from './OptionsPane';
import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems';
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy';
@ -45,6 +45,7 @@ import { getVariables } from 'app/features/variables/state/selectors';
import { StoreState } from 'app/types';
import { DisplayMode, displayModes, PanelEditorTab } from './types';
import { DashboardModel, PanelModel } from '../../state';
import { VisualizationButton } from './VisualizationButton';
import { PanelOptionsChangedEvent, ShowModalReactEvent } from 'app/types/events';
import { locationService } from '@grafana/runtime';
import { UnlinkModal } from '../../../library-panels/components/UnlinkModal/UnlinkModal';
@ -286,20 +287,15 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
}
renderPanelToolbar(styles: EditorStyles) {
const { dashboard, uiState, variables, updateTimeZoneForSession } = this.props;
const { dashboard, uiState, variables, updateTimeZoneForSession, panel } = this.props;
return (
<div className={styles.panelToolbar}>
<HorizontalGroup justify={variables.length > 0 ? 'space-between' : 'flex-end'} align="flex-start">
{this.renderTemplateVariables(styles)}
<HorizontalGroup>
<RadioButtonGroup value={uiState.mode} options={displayModes} onChange={this.onDisplayModeChange} />
<DashNavTimeControls dashboard={dashboard} onChangeTimeZone={updateTimeZoneForSession} />
{!uiState.isPanelOptionsVisible && (
<ToolbarButton onClick={this.onTogglePanelOptions} tooltip="Open options pane" icon="angle-left">
Show options
</ToolbarButton>
)}
{!uiState.isPanelOptionsVisible && <VisualizationButton panel={panel} />}
</HorizontalGroup>
</HorizontalGroup>
</div>
@ -390,12 +386,11 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
}
return (
<OptionsPaneContent
<OptionsPane
plugin={plugin}
dashboard={dashboard}
panel={panel}
width={rightPaneSize}
onClose={this.onTogglePanelOptions}
onFieldConfigsChange={this.onFieldConfigChange}
onPanelOptionsChanged={this.onPanelOptionsChanged}
onPanelConfigChange={this.onPanelConfigChanged}

View File

@ -1,93 +0,0 @@
import React, { useMemo } from 'react';
import {
DataFrame,
EventBus,
InterpolateFunction,
PanelOptionsEditorItem,
PanelPlugin,
StandardEditorContext,
VariableSuggestionsScope,
} from '@grafana/data';
import { get as lodashGet, set as lodashSet } from 'lodash';
import { Field, Label } from '@grafana/ui';
import groupBy from 'lodash/groupBy';
import { OptionsGroup } from './OptionsGroup';
import { getPanelOptionsVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
interface PanelOptionsEditorProps<TOptions> {
plugin: PanelPlugin;
data?: DataFrame[];
replaceVariables: InterpolateFunction;
eventBus: EventBus;
options: TOptions;
onChange: (options: TOptions) => void;
}
const DISPLAY_OPTIONS_CATEGORY = 'Display';
export const PanelOptionsEditor: React.FC<PanelOptionsEditorProps<any>> = ({
plugin,
options,
onChange,
data,
eventBus,
replaceVariables,
}) => {
const optionEditors = useMemo<Record<string, PanelOptionsEditorItem[]>>(() => {
return groupBy(plugin.optionEditors.list(), (i) => {
if (!i.category) {
return DISPLAY_OPTIONS_CATEGORY;
}
return i.category[0] ? i.category[0] : DISPLAY_OPTIONS_CATEGORY;
});
}, [plugin]);
const onOptionChange = (key: string, value: any) => {
const newOptions = lodashSet({ ...options }, key, value);
onChange(newOptions);
};
const context: StandardEditorContext<any> = {
data: data || [],
replaceVariables,
options,
eventBus,
getSuggestions: (scope?: VariableSuggestionsScope) => {
return getPanelOptionsVariableSuggestions(plugin, data);
},
};
return (
<>
{Object.keys(optionEditors).map((c, i) => {
const optionsToShow = optionEditors[c]
.map((e, j) => {
if (e.showIf && !e.showIf(options, data)) {
return null;
}
const label = (
<Label description={e.description} category={e.category?.slice(1) as string[]}>
{e.name}
</Label>
);
return (
<Field label={label} key={`${e.id}/${j}`}>
<e.editor
value={lodashGet(options, e.path)}
onChange={(value) => onOptionChange(e.path, value)}
item={e}
context={context}
/>
</Field>
);
})
.filter((e) => e !== null);
return optionsToShow.length > 0 ? (
<OptionsGroup title={c} defaultToClosed id={`${c}/${i}`} key={`${c}/${i}`}>
<div>{optionsToShow}</div>
</OptionsGroup>
) : null;
})}
</>
);
};

View File

@ -1,178 +0,0 @@
import React, { FC, useCallback, useMemo, useRef } from 'react';
import { DashboardModel, PanelModel } from '../../state';
import { PanelData, PanelPlugin } from '@grafana/data';
import { Counter, DataLinksInlineEditor, Field, Input, RadioButtonGroup, Select, Switch, TextArea } from '@grafana/ui';
import { getPanelLinksVariableSuggestions } from '../../../panel/panellinks/link_srv';
import { PanelOptionsEditor } from './PanelOptionsEditor';
import { AngularPanelOptions } from './AngularPanelOptions';
import { VisualizationTab } from './VisualizationTab';
import { OptionsGroup } from './OptionsGroup';
import { RepeatRowSelect } from '../RepeatRowSelect/RepeatRowSelect';
import config from 'app/core/config';
import { LibraryPanelInformation } from 'app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo';
import { isLibraryPanel } from '../../state/PanelModel';
import { PanelLibraryOptionsGroup } from 'app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup';
interface Props {
panel: PanelModel;
plugin: PanelPlugin;
data?: PanelData;
dashboard: DashboardModel;
onPanelConfigChange: (configKey: string, value: any) => void;
onPanelOptionsChanged: (options: any) => void;
}
export const PanelOptionsTab: FC<Props> = ({
panel,
plugin,
data,
dashboard,
onPanelConfigChange,
onPanelOptionsChanged,
}) => {
const visTabInputRef = useRef<HTMLInputElement>(null);
const makeDummyEdit = useCallback(() => onPanelConfigChange('isEditing', true), []);
const linkVariablesSuggestions = useMemo(() => getPanelLinksVariableSuggestions(), []);
const onRepeatRowSelectChange = useCallback((value: string | null) => onPanelConfigChange('repeat', value), [
onPanelConfigChange,
]);
const elements: JSX.Element[] = [];
const panelLinksCount = panel && panel.links ? panel.links.length : 0;
const directionOptions = [
{ label: 'Horizontal', value: 'h' },
{ label: 'Vertical', value: 'v' },
];
const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value }));
const focusVisPickerInput = (isExpanded: boolean) => {
if (isExpanded && visTabInputRef.current) {
visTabInputRef.current.focus();
}
};
if (config.featureToggles.panelLibrary && isLibraryPanel(panel)) {
elements.push(
<LibraryPanelInformation
panel={panel}
formatDate={(dateString, format) => dashboard.formatDate(dateString, format)}
key="Library Panel Information"
/>
);
}
// First common panel settings Title, description
elements.push(
<OptionsGroup title="Settings" id="Panel settings" key="Panel settings">
<Field label="Panel title">
<Input defaultValue={panel.title} onBlur={(e) => onPanelConfigChange('title', e.currentTarget.value)} />
</Field>
<Field label="Description" description="Panel description supports markdown and links.">
<TextArea
defaultValue={panel.description}
onBlur={(e) => onPanelConfigChange('description', e.currentTarget.value)}
/>
</Field>
<Field label="Transparent" description="Display panel without a background.">
<Switch
value={panel.transparent}
onChange={(e) => onPanelConfigChange('transparent', e.currentTarget.checked)}
/>
</Field>
</OptionsGroup>
);
elements.push(
<OptionsGroup title="Visualization" id="Panel type" key="Panel type" defaultToClosed onToggle={focusVisPickerInput}>
{(toggleExpand) => <VisualizationTab panel={panel} ref={visTabInputRef} onToggleOptionGroup={toggleExpand} />}
</OptionsGroup>
);
// Old legacy react editor
if (plugin.editor && panel && !plugin.optionEditors) {
elements.push(
<OptionsGroup title="Options" id="legacy react editor" key="legacy react editor">
<plugin.editor data={data} options={panel.getOptions()} onOptionsChange={onPanelOptionsChanged} />
</OptionsGroup>
);
}
if (plugin.optionEditors && panel) {
elements.push(
<PanelOptionsEditor
key="panel options"
options={panel.getOptions()}
onChange={onPanelOptionsChanged}
replaceVariables={panel.replaceVariables}
plugin={plugin}
data={data?.series}
eventBus={dashboard.events}
/>
);
}
if (plugin.angularPanelCtrl) {
elements.push(
<AngularPanelOptions panel={panel} dashboard={dashboard} plugin={plugin} key="angular panel options" />
);
}
elements.push(
<OptionsGroup
renderTitle={(isExpanded) => (
<>Links {!isExpanded && panelLinksCount > 0 && <Counter value={panelLinksCount} />}</>
)}
id="panel links"
key="panel links"
defaultToClosed
>
<DataLinksInlineEditor
links={panel.links}
onChange={(links) => onPanelConfigChange('links', links)}
suggestions={linkVariablesSuggestions}
data={[]}
/>
</OptionsGroup>
);
elements.push(
<OptionsGroup title="Repeat options" id="panel repeats" key="panel repeats" defaultToClosed>
<Field
label="Repeat by variable"
description="Repeat this panel for each value in the selected variable.
This is not visible while in edit mode. You need to go back to dashboard and then update the variable or
reload the dashboard."
>
<RepeatRowSelect repeat={panel.repeat} onChange={onRepeatRowSelectChange} />
</Field>
{panel.repeat && (
<Field label="Repeat direction">
<RadioButtonGroup
options={directionOptions}
value={panel.repeatDirection || 'h'}
onChange={(value) => onPanelConfigChange('repeatDirection', value)}
/>
</Field>
)}
{panel.repeat && panel.repeatDirection === 'h' && (
<Field label="Max per row">
<Select
options={maxPerRowOptions}
value={panel.maxPerRow}
onChange={(value) => onPanelConfigChange('maxPerRow', value.value)}
/>
</Field>
)}
</OptionsGroup>
);
if (config.featureToggles.panelLibrary) {
elements.push(
<PanelLibraryOptionsGroup panel={panel} dashboard={dashboard} onChange={makeDummyEdit} key="Panel Library" />
);
}
return <>{elements}</>;
};

View File

@ -0,0 +1,103 @@
import React, { FC } from 'react';
import { css } from 'emotion';
import { GrafanaTheme, PanelPlugin } from '@grafana/data';
import { ToolbarButton, ButtonGroup, useStyles } from '@grafana/ui';
import { StoreState } from 'app/types';
import { connect, MapStateToProps, MapDispatchToProps } from 'react-redux';
import { setPanelEditorUIState, toggleVizPicker } from './state/reducers';
import { selectors } from '@grafana/e2e-selectors';
import { PanelModel } from '../../state';
interface OwnProps {
panel: PanelModel;
}
interface ConnectedProps {
plugin?: PanelPlugin;
isVizPickerOpen: boolean;
isPanelOptionsVisible: boolean;
}
interface DispatchProps {
toggleVizPicker: typeof toggleVizPicker;
setPanelEditorUIState: typeof setPanelEditorUIState;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
export const VisualizationButtonUnconnected: FC<Props> = ({
plugin,
toggleVizPicker,
isPanelOptionsVisible,
isVizPickerOpen,
setPanelEditorUIState,
}) => {
const styles = useStyles(getStyles);
const onToggleOpen = () => {
toggleVizPicker(!isVizPickerOpen);
};
const onToggleOptionsPane = () => {
setPanelEditorUIState({ isPanelOptionsVisible: !isPanelOptionsVisible });
};
if (!plugin) {
return null;
}
return (
<div className={styles.wrapper}>
<ButtonGroup>
<ToolbarButton
className={styles.vizButton}
tooltip="Click to change visualisation"
imgSrc={plugin.meta.info.logos.small}
isOpen={isVizPickerOpen}
onClick={onToggleOpen}
aria-label={selectors.components.PanelEditor.toggleVizPicker}
fullWidth
>
{plugin.meta.name}
</ToolbarButton>
<ToolbarButton
tooltip={isPanelOptionsVisible ? 'Close options pane' : 'Show options pane'}
icon={isPanelOptionsVisible ? 'angle-right' : 'angle-left'}
onClick={onToggleOptionsPane}
aria-label={selectors.components.PanelEditor.toggleVizOptions}
/>
</ButtonGroup>
</div>
);
};
VisualizationButtonUnconnected.displayName = 'VisualizationTabUnconnected';
const getStyles = (theme: GrafanaTheme) => {
return {
wrapper: css`
display: flex;
flex-direction: column;
`,
vizButton: css`
text-align: left;
`,
};
};
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
return {
plugin: state.plugins.panels[props.panel.type],
isPanelOptionsVisible: state.panelEditor.ui.isPanelOptionsVisible,
isVizPickerOpen: state.panelEditor.isVizPickerOpen,
};
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
toggleVizPicker,
setPanelEditorUIState,
};
export const VisualizationButton = connect(mapStateToProps, mapDispatchToProps, undefined, { forwardRef: true })(
VisualizationButtonUnconnected
);

View File

@ -0,0 +1,177 @@
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { css } from 'emotion';
import { GrafanaTheme, PanelPluginMeta, SelectableValue } from '@grafana/data';
import { Icon, Input, RadioButtonGroup, CustomScrollbar, useStyles, Button } from '@grafana/ui';
import { changePanelPlugin } from '../../state/actions';
import { StoreState } from 'app/types';
import { PanelModel } from '../../state/PanelModel';
import { useDispatch, useSelector } from 'react-redux';
import { VizTypePicker, getAllPanelPluginMeta, filterPluginList } from '../VizTypePicker/VizTypePicker';
import { Field } from '@grafana/ui/src/components/Forms/Field';
import { PanelLibraryOptionsGroup } from 'app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup';
import { toggleVizPicker } from './state/reducers';
import { selectors } from '@grafana/e2e-selectors';
import { config } from 'app/core/config';
interface Props {
panel: PanelModel;
}
export const VisualizationSelectPane: FC<Props> = ({ panel }) => {
const plugin = useSelector((state: StoreState) => state.plugins.panels[panel.type]);
const [searchQuery, setSearchQuery] = useState('');
const [listMode, setListMode] = useState(ListMode.Visualizations);
const dispatch = useDispatch();
const styles = useStyles(getStyles);
const searchRef = useRef<HTMLInputElement | null>(null);
const onPluginTypeChange = (meta: PanelPluginMeta) => {
if (meta.id === plugin.meta.id) {
dispatch(toggleVizPicker(false));
} else {
dispatch(changePanelPlugin(panel, meta.id));
}
};
// Give Search input focus when using radio button switch list mode
useEffect(() => {
if (searchRef.current) {
searchRef.current.focus();
}
}, [listMode]);
const onCloseVizPicker = () => {
dispatch(toggleVizPicker(false));
};
const onKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const query = e.currentTarget.value;
const plugins = getAllPanelPluginMeta();
const match = filterPluginList(plugins, query, plugin.meta);
if (match && match.length) {
onPluginTypeChange(match[0]);
}
}
},
[onPluginTypeChange]
);
const suffix =
searchQuery !== '' ? (
<Button icon="times" variant="link" size="sm" onClick={() => setSearchQuery('')}>
Clear
</Button>
) : null;
if (!plugin) {
return null;
}
const radioOptions: Array<SelectableValue<ListMode>> = [
{ label: 'Visualizations', value: ListMode.Visualizations },
{ label: 'Global panels', value: ListMode.Globals },
];
return (
<div className={styles.openWrapper}>
<div className={styles.formBox}>
<div className={styles.searchRow}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
onKeyPress={onKeyPress}
prefix={<Icon name="search" />}
suffix={suffix}
ref={searchRef}
placeholder="Search for..."
/>
<Button
title="Close"
variant="secondary"
icon="angle-up"
className={styles.closeButton}
aria-label={selectors.components.PanelEditor.toggleVizPicker}
onClick={onCloseVizPicker}
/>
</div>
{config.featureToggles.panelLibrary && (
<Field className={styles.customFieldMargin}>
<RadioButtonGroup options={radioOptions} value={listMode} onChange={setListMode} fullWidth />
</Field>
)}
</div>
<div className={styles.scrollWrapper}>
<CustomScrollbar autoHeightMin="100%">
<div className={styles.scrollContent}>
{listMode === ListMode.Visualizations && (
<VizTypePicker
current={plugin.meta}
onTypeChange={onPluginTypeChange}
searchQuery={searchQuery}
onClose={() => {}}
/>
)}
{listMode === ListMode.Globals && (
<PanelLibraryOptionsGroup searchQuery={searchQuery} panel={panel} key="Panel Library" />
)}
</div>
</CustomScrollbar>
</div>
</div>
);
};
enum ListMode {
Visualizations,
Globals,
}
VisualizationSelectPane.displayName = 'VisualizationSelectPane';
const getStyles = (theme: GrafanaTheme) => {
return {
icon: css`
color: ${theme.palette.gray33};
`,
wrapper: css`
display: flex;
flex-direction: column;
flex: 1 1 0;
height: 100%;
`,
vizButton: css`
text-align: left;
`,
scrollWrapper: css`
flex-grow: 1;
min-height: 0;
`,
scrollContent: css`
padding: ${theme.spacing.sm};
`,
openWrapper: css`
display: flex;
flex-direction: column;
flex: 1 1 0;
height: 100%;
background: ${theme.colors.bg1};
border: 1px solid ${theme.colors.border1};
`,
searchRow: css`
display: flex;
margin-bottom: ${theme.spacing.sm};
`,
closeButton: css`
margin-left: ${theme.spacing.sm};
`,
customFieldMargin: css`
margin-bottom: ${theme.spacing.sm};
`,
formBox: css`
padding: ${theme.spacing.sm};
padding-bottom: 0;
`,
};
};

View File

@ -1,120 +0,0 @@
import React, { useCallback, useState } from 'react';
import { css } from 'emotion';
import { GrafanaTheme, PanelPlugin, PanelPluginMeta } from '@grafana/data';
import { useTheme, stylesFactory, Icon, Input } from '@grafana/ui';
import { changePanelPlugin } from '../../state/actions';
import { StoreState } from 'app/types';
import { PanelModel } from '../../state/PanelModel';
import { connect, MapStateToProps, MapDispatchToProps } from 'react-redux';
import { VizTypePicker, getAllPanelPluginMeta, filterPluginList } from '../VizTypePicker/VizTypePicker';
import { Field } from '@grafana/ui/src/components/Forms/Field';
interface OwnProps {
panel: PanelModel;
onToggleOptionGroup: (expand: boolean) => void;
}
interface ConnectedProps {
plugin: PanelPlugin;
}
interface DispatchProps {
changePanelPlugin: typeof changePanelPlugin;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
export const VisualizationTabUnconnected = React.forwardRef<HTMLInputElement, Props>(
({ panel, plugin, changePanelPlugin, onToggleOptionGroup }, ref) => {
const [searchQuery, setSearchQuery] = useState('');
const theme = useTheme();
const styles = getStyles(theme);
const onPluginTypeChange = (meta: PanelPluginMeta) => {
if (meta.id === plugin.meta.id) {
onToggleOptionGroup(false);
} else {
changePanelPlugin(panel, meta.id);
}
};
const onKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const query = e.currentTarget.value;
const plugins = getAllPanelPluginMeta();
const match = filterPluginList(plugins, query, plugin.meta);
if (match && match.length) {
onPluginTypeChange(match[0]);
}
}
},
[onPluginTypeChange]
);
const suffix =
searchQuery !== '' ? (
<span className={styles.searchClear} onClick={() => setSearchQuery('')}>
<Icon name="times" />
Clear filter
</span>
) : null;
if (!plugin) {
return null;
}
return (
<div className={styles.wrapper}>
<Field>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
onKeyPress={onKeyPress}
prefix={<Icon name="filter" className={styles.icon} />}
suffix={suffix}
placeholder="Filter visualizations"
ref={ref}
/>
</Field>
<VizTypePicker
current={plugin.meta}
onTypeChange={onPluginTypeChange}
searchQuery={searchQuery}
onClose={() => {}}
/>
</div>
);
}
);
VisualizationTabUnconnected.displayName = 'VisualizationTabUnconnected';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
icon: css`
color: ${theme.palette.gray33};
`,
wrapper: css`
display: flex;
flex-direction: column;
`,
searchClear: css`
color: ${theme.palette.gray60};
cursor: pointer;
`,
};
});
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, props) => {
return {
plugin: state.plugins.panels[props.panel.type],
};
};
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { changePanelPlugin };
export const VisualizationTab = connect(mapStateToProps, mapDispatchToProps, undefined, { forwardRef: true })(
VisualizationTabUnconnected
);

View File

@ -0,0 +1,253 @@
import React from 'react';
import { cloneDeep } from 'lodash';
import {
FieldConfigOptionsRegistry,
SelectableValue,
isSystemOverride as isSystemOverrideGuard,
VariableSuggestionsScope,
DynamicConfigValue,
} from '@grafana/data';
import { Container, fieldMatchersUI, ValuePicker } from '@grafana/ui';
import { OptionPaneRenderProps } from './types';
import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor';
import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor';
import { DynamicConfigValueEditor } from './DynamicConfigValueEditor';
import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
import { OverrideCategoryTitle } from './OverrideCategoryTitle';
export function getFieldOverrideCategories(props: OptionPaneRenderProps): OptionsPaneCategoryDescriptor[] {
const categories: OptionsPaneCategoryDescriptor[] = [];
const currentFieldConfig = props.panel.fieldConfig;
const registry = props.plugin.fieldConfigRegistry;
const data = props.data?.series ?? [];
const onOverrideChange = (index: number, override: any) => {
let overrides = cloneDeep(currentFieldConfig.overrides);
overrides[index] = override;
props.onFieldConfigsChange({ ...currentFieldConfig, overrides });
};
const onOverrideRemove = (overrideIndex: number) => {
let overrides = cloneDeep(currentFieldConfig.overrides);
overrides.splice(overrideIndex, 1);
props.onFieldConfigsChange({ ...currentFieldConfig, overrides });
};
const onOverrideAdd = (value: SelectableValue<string>) => {
props.onFieldConfigsChange({
...currentFieldConfig,
overrides: [
...currentFieldConfig.overrides,
{
matcher: {
id: value.value!,
},
properties: [],
},
],
});
};
const context = {
data,
getSuggestions: (scope?: VariableSuggestionsScope) => getDataLinksVariableSuggestions(data, scope),
};
/**
* Main loop through all override rules
*/
for (let idx = 0; idx < currentFieldConfig.overrides.length; idx++) {
const override = currentFieldConfig.overrides[idx];
const overrideName = `Override ${idx + 1}`;
const matcherUi = fieldMatchersUI.get(override.matcher.id);
const configPropertiesOptions = getOverrideProperties(registry);
const isSystemOverride = isSystemOverrideGuard(override);
// A way force open new override categories
const forceOpen = override.properties.length === 0 ? 1 : 0;
const category = new OptionsPaneCategoryDescriptor({
title: overrideName,
id: overrideName,
forceOpen,
renderTitle: function renderOverrideTitle(isExpanded: boolean) {
return (
<OverrideCategoryTitle
override={override}
isExpanded={isExpanded}
registry={registry}
overrideName={overrideName}
matcherUi={matcherUi}
onOverrideRemove={() => onOverrideRemove(idx)}
/>
);
},
});
const onMatcherConfigChange = (options: any) => {
override.matcher.options = options;
onOverrideChange(idx, override);
};
const onDynamicConfigValueAdd = (value: SelectableValue<string>) => {
const registryItem = registry.get(value.value!);
const propertyConfig: DynamicConfigValue = {
id: registryItem.id,
value: registryItem.defaultValue,
};
if (override.properties) {
override.properties.push(propertyConfig);
} else {
override.properties = [propertyConfig];
}
onOverrideChange(idx, override);
};
/**
* Add override matcher UI element
*/
category.addItem(
new OptionsPaneItemDescriptor({
title: matcherUi.name,
Component: function renderMatcherUI() {
return (
<matcherUi.component
matcher={matcherUi.matcher}
data={props.data?.series ?? []}
options={override.matcher.options}
onChange={onMatcherConfigChange}
/>
);
},
})
);
/**
* Loop through all override properties
*/
for (let propIdx = 0; propIdx < override.properties.length; propIdx++) {
const property = override.properties[propIdx];
const registryItemForProperty = registry.getIfExists(property.id);
if (!registryItemForProperty) {
continue;
}
const onPropertyChange = (value: any) => {
override.properties[propIdx].value = value;
onOverrideChange(idx, override);
};
const onPropertyRemove = () => {
override.properties.splice(propIdx, 1);
onOverrideChange(idx, override);
};
/**
* Add override property item
*/
category.addItem(
new OptionsPaneItemDescriptor({
title: registryItemForProperty.name,
skipField: true,
Component: function renderPropertyEditor() {
return (
<DynamicConfigValueEditor
key={`${property.id}/${propIdx}`}
isSystemOverride={isSystemOverride}
onChange={onPropertyChange}
onRemove={onPropertyRemove}
property={property}
registry={registry}
context={context}
/>
);
},
})
);
}
/**
* Add button that adds new overrides
*/
if (!isSystemOverride && override.matcher.options) {
category.addItem(
new OptionsPaneItemDescriptor({
title: '----------',
skipField: true,
Component: function renderAddPropertyButton() {
return (
<ValuePicker
label="Add override property"
variant="secondary"
isFullWidth={false}
icon="plus"
menuPlacement="auto"
options={configPropertiesOptions}
onChange={onDynamicConfigValueAdd}
/>
);
},
})
);
}
categories.push(category);
}
categories.push(
new OptionsPaneCategoryDescriptor({
title: 'add button',
id: 'add button',
customRender: function renderAddButton() {
return (
<Container padding="md" key="Add override">
<ValuePicker
icon="plus"
label="Add a field override"
variant="secondary"
size="sm"
menuPlacement="auto"
isFullWidth={false}
options={fieldMatchersUI
.list()
.filter((o) => !o.excludeFromPicker)
.map<SelectableValue<string>>((i) => ({ label: i.name, value: i.id, description: i.description }))}
onChange={(value) => onOverrideAdd(value)}
/>
</Container>
);
},
})
);
// <FeatureInfoBox
// title="Overrides"
// url={getDocsLink(DocsId.FieldConfigOverrides)}
// className={css`
// margin: ${theme.spacing.md};
// `}
// >
// Field override rules give you a fine grained control over how your data is displayed.
// </FeatureInfoBox>
return categories;
}
function getOverrideProperties(registry: FieldConfigOptionsRegistry) {
return registry
.list()
.filter((o) => !o.hideFromOverrides)
.map((item) => {
let label = item.name;
if (item.category && item.category.length > 1) {
label = [...item.category!.slice(1), item.name].join(' > ');
}
return {
label,
value: item.id,
description: item.description,
};
});
}

View File

@ -0,0 +1,143 @@
import { DataLinksInlineEditor, Input, RadioButtonGroup, Select, Switch, TextArea } from '@grafana/ui';
import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
import React from 'react';
import { RepeatRowSelect } from '../RepeatRowSelect/RepeatRowSelect';
import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor';
import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor';
import { OptionPaneRenderProps } from './types';
export function getPanelFrameCategory(props: OptionPaneRenderProps): OptionsPaneCategoryDescriptor {
const { panel, onPanelConfigChange } = props;
return new OptionsPaneCategoryDescriptor({
title: 'Panel options',
id: 'Panel options',
isOpenDefault: true,
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Title',
value: panel.title,
popularRank: 1,
Component: function renderTitle() {
return (
<Input
id="PanelFrameTitle"
defaultValue={panel.title}
onBlur={(e) => onPanelConfigChange('title', e.currentTarget.value)}
/>
);
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Description',
description: panel.description,
value: panel.description,
Component: function renderDescription() {
return (
<TextArea
defaultValue={panel.description}
onBlur={(e) => onPanelConfigChange('description', e.currentTarget.value)}
/>
);
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Transparent background',
Component: function renderTransparent() {
return (
<Switch
value={panel.transparent}
onChange={(e) => onPanelConfigChange('transparent', e.currentTarget.checked)}
/>
);
},
})
)
.addCategory(
new OptionsPaneCategoryDescriptor({
title: 'Panel links',
id: 'Panel links',
isOpenDefault: false,
itemsCount: panel.links?.length,
}).addItem(
new OptionsPaneItemDescriptor({
title: 'Panel links',
Component: function renderLinks() {
return (
<DataLinksInlineEditor
links={panel.links}
onChange={(links) => onPanelConfigChange('links', links)}
getSuggestions={getPanelLinksVariableSuggestions}
data={[]}
/>
);
},
})
)
)
.addCategory(
new OptionsPaneCategoryDescriptor({
title: 'Repeat options',
id: 'Repeat options',
isOpenDefault: false,
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat by variable',
description:
'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.',
Component: function renderRepeatOptions() {
return (
<RepeatRowSelect
repeat={panel.repeat}
onChange={(value: string | null) => {
onPanelConfigChange('repeat', value);
}}
/>
);
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Repeat direction',
showIf: () => !!panel.repeat,
Component: function renderRepeatOptions() {
const directionOptions = [
{ label: 'Horizontal', value: 'h' },
{ label: 'Vertical', value: 'v' },
];
return (
<RadioButtonGroup
options={directionOptions}
value={panel.repeatDirection || 'h'}
onChange={(value) => onPanelConfigChange('repeatDirection', value)}
/>
);
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Max per row',
showIf: () => Boolean(panel.repeat && panel.repeatDirection === 'h'),
Component: function renderOption() {
const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value }));
return (
<Select
options={maxPerRowOptions}
value={panel.maxPerRow}
onChange={(value) => onPanelConfigChange('maxPerRow', value.value)}
/>
);
},
})
)
);
}

View File

@ -0,0 +1,118 @@
import React from 'react';
import { StandardEditorContext, VariableSuggestionsScope } from '@grafana/data';
import { get as lodashGet, set as lodashSet } from 'lodash';
import { getPanelOptionsVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
import { OptionPaneRenderProps } from './types';
import { updateDefaultFieldConfigValue } from './utils';
import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor';
import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor';
export function getVizualizationOptions(props: OptionPaneRenderProps): OptionsPaneCategoryDescriptor[] {
const { plugin, panel, onPanelOptionsChanged, onFieldConfigsChange, data, dashboard } = props;
const currentOptions = panel.getOptions();
const currentFieldConfig = panel.fieldConfig;
const categoryIndex: Record<string, OptionsPaneCategoryDescriptor> = {};
const context: StandardEditorContext<any> = {
data: data?.series || [],
replaceVariables: panel.replaceVariables,
options: currentOptions,
eventBus: dashboard.events,
getSuggestions: (scope?: VariableSuggestionsScope) => {
return getPanelOptionsVariableSuggestions(plugin, data?.series);
},
};
const getOptionsPaneCategory = (categoryNames?: string[]): OptionsPaneCategoryDescriptor => {
const categoryName = (categoryNames && categoryNames[0]) ?? `${plugin.meta.name}`;
const category = categoryIndex[categoryName];
if (category) {
return category;
}
return (categoryIndex[categoryName] = new OptionsPaneCategoryDescriptor({
title: categoryName,
id: categoryName,
}));
};
/**
* Panel options
*/
for (const pluginOption of plugin.optionEditors.list()) {
const category = getOptionsPaneCategory(pluginOption.category);
const Editor = pluginOption.editor;
category.addItem(
new OptionsPaneItemDescriptor({
title: pluginOption.name,
description: pluginOption.description,
Component: function renderEditor() {
const onChange = (value: any) => {
const newOptions = lodashSet({ ...currentOptions }, pluginOption.path, value);
onPanelOptionsChanged(newOptions);
};
return (
<Editor
value={lodashGet(currentOptions, pluginOption.path)}
onChange={onChange}
item={pluginOption}
context={context}
/>
);
},
})
);
}
/**
* Field options
*/
for (const fieldOption of plugin.fieldConfigRegistry.list()) {
if (
fieldOption.isCustom &&
fieldOption.showIf &&
!fieldOption.showIf(currentFieldConfig.defaults.custom, data?.series)
) {
continue;
}
if (fieldOption.hideFromDefaults) {
continue;
}
const category = getOptionsPaneCategory(fieldOption.category);
const Editor = fieldOption.editor;
const defaults = currentFieldConfig.defaults;
const value = fieldOption.isCustom
? defaults.custom
? lodashGet(defaults.custom, fieldOption.path)
: undefined
: lodashGet(defaults, fieldOption.path);
if (fieldOption.getItemsCount) {
category.props.itemsCount = fieldOption.getItemsCount(value);
}
category.addItem(
new OptionsPaneItemDescriptor({
title: fieldOption.name,
description: fieldOption.description,
Component: function renderEditor() {
const onChange = (v: any) => {
onFieldConfigsChange(
updateDefaultFieldConfigValue(currentFieldConfig, fieldOption.path, v, fieldOption.isCustom)
);
};
return <Editor value={value} onChange={onChange} item={fieldOption} context={context} />;
},
})
);
}
return Object.values(categoryIndex);
}

View File

@ -0,0 +1,141 @@
import { OptionsPaneCategoryDescriptor } from '../OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from '../OptionsPaneItemDescriptor';
import { OptionSearchEngine } from './OptionSearchEngine';
describe('OptionSearchEngine', () => {
it('Can search options based on title', () => {
const engine = new OptionSearchEngine(getOptionCategories(), []);
const results = engine.search('Min');
expect(results.optionHits.length).toBe(2);
expect(results.optionHits[0].props.title).toBe('Min');
});
it('When matching both title and description title should rank higher', () => {
const engine = new OptionSearchEngine(getOptionCategories(), []);
const results = engine.search('DescriptionMatch');
expect(results.optionHits.length).toBe(2);
expect(results.optionHits[0].props.title).toBe('DescriptionMatch');
});
it('When matching both category and title category title should rank higher', () => {
const engine = new OptionSearchEngine(getOptionCategories(), []);
const results = engine.search('frame');
expect(results.optionHits.length).toBe(4);
expect(results.optionHits[0].props.title).toBe('Frame');
});
it('Override hits should contain matcher and matched properties', () => {
const engine = new OptionSearchEngine(getOptionCategories(), getOverrides());
const results = engine.search('Max');
expect(results.overrideHits.length).toBe(2);
expect(results.overrideHits[0].items.length).toBe(2);
expect(results.overrideHits[0].items[0].props.title).toBe('Match by name');
expect(results.overrideHits[0].items[1].props.title).toBe('Max');
});
it('Override hits should not add matcher twice', () => {
const engine = new OptionSearchEngine(getOptionCategories(), getOverrides());
const results = engine.search('Match by name');
expect(results.overrideHits.length).toBe(2);
expect(results.overrideHits[0].items.length).toBe(1);
});
});
function getOptionCategories(): OptionsPaneCategoryDescriptor[] {
return [
new OptionsPaneCategoryDescriptor({
id: 'Panel frame',
title: 'Panel frame',
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Title',
Component: jest.fn(),
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Min',
Component: jest.fn(),
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'ASDSADASDSADA',
description: 'DescriptionMatch',
Component: jest.fn(),
})
),
new OptionsPaneCategoryDescriptor({
id: 'Axis',
title: 'Axis',
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Min',
Component: jest.fn(),
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'DescriptionMatch',
Component: jest.fn(),
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Frame',
Component: jest.fn(),
})
),
];
}
function getOverrides(): OptionsPaneCategoryDescriptor[] {
return [
new OptionsPaneCategoryDescriptor({
id: 'Override 1',
title: 'Override 1',
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Match by name',
Component: jest.fn(),
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Min',
Component: jest.fn(),
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Max',
Component: jest.fn(),
})
),
new OptionsPaneCategoryDescriptor({
id: 'Override 2',
title: 'Override 2',
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Match by name',
Component: jest.fn(),
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Threshold',
Component: jest.fn(),
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Max',
Component: jest.fn(),
})
),
];
}

View File

@ -0,0 +1,101 @@
import { OptionsPaneItemDescriptor } from '../OptionsPaneItemDescriptor';
import { OptionsPaneCategoryDescriptor } from '../OptionsPaneCategoryDescriptor';
export interface OptionSearchResults {
optionHits: OptionsPaneItemDescriptor[];
overrideHits: OptionsPaneCategoryDescriptor[];
totalCount: number;
}
export class OptionSearchEngine {
constructor(
private categories: OptionsPaneCategoryDescriptor[],
private overrides: OptionsPaneCategoryDescriptor[]
) {}
search(query: string): OptionSearchResults {
const searchRegex = new RegExp(query, 'i');
const optionHits = this.collectHits(this.categories, searchRegex, []);
const sortedHits = optionHits.sort(compareHit).map((x) => x.item);
const overrideHits = this.collectHits(this.overrides, searchRegex, []);
const sortedOverridesHits = overrideHits.sort(compareHit).map((x) => x.item);
return {
optionHits: sortedHits,
overrideHits: this.buildOverrideHitCategories(sortedOverridesHits),
totalCount: this.getAllOptionsCount(this.categories),
};
}
private collectHits(categories: OptionsPaneCategoryDescriptor[], searchRegex: RegExp, hits: SearchHit[]) {
for (const category of categories) {
const categoryNameMatch = searchRegex.test(category.props.title);
for (const item of category.items) {
if (searchRegex.test(item.props.title)) {
hits.push({ item: item, rank: 1 });
continue;
}
if (item.props.description && searchRegex.test(item.props.description)) {
hits.push({ item: item, rank: 2 });
continue;
}
if (categoryNameMatch) {
hits.push({ item: item, rank: 3 });
}
}
if (category.categories.length > 0) {
this.collectHits(category.categories, searchRegex, hits);
}
}
return hits;
}
getAllOptionsCount(categories: OptionsPaneCategoryDescriptor[]) {
var total = 0;
for (const category of categories) {
total += category.items.length;
if (category.categories.length > 0) {
total += this.getAllOptionsCount(category.categories);
}
}
return total;
}
buildOverrideHitCategories(hits: OptionsPaneItemDescriptor[]): OptionsPaneCategoryDescriptor[] {
const categories: Record<string, OptionsPaneCategoryDescriptor> = {};
for (const hit of hits) {
let category = categories[hit.parent.props.title];
if (!category) {
category = categories[hit.parent.props.title] = new OptionsPaneCategoryDescriptor(hit.parent.props);
// Add matcher item as that should always be shown
category.addItem(hit.parent.items[0]);
}
// Prevent adding matcher twice since it's automatically added for every override
if (category.items[0].props.title !== hit.props.title) {
category.addItem(hit);
}
}
return Object.values(categories);
}
}
interface SearchHit {
item: OptionsPaneItemDescriptor;
rank: number;
}
function compareHit(left: SearchHit, right: SearchHit) {
return left.rank - right.rank;
}

View File

@ -0,0 +1,29 @@
import { OptionsPaneCategoryDescriptor } from '../OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from '../OptionsPaneItemDescriptor';
export function getRecentOptions(allOptions: OptionsPaneCategoryDescriptor[]) {
const popularOptions: OptionsPaneItemDescriptor[] = [];
for (const category of allOptions) {
for (const item of category.items) {
if (item.props.title === 'Unit') {
item.props.popularRank = 2;
}
if (item.props.title === 'Min') {
item.props.popularRank = 3;
}
if (item.props.title === 'Max') {
item.props.popularRank = 4;
}
if (item.props.title === 'Display name') {
item.props.popularRank = 5;
}
if (item.props.popularRank) {
popularOptions.push(item);
}
}
}
return popularOptions.sort((left, right) => left.props.popularRank! - right.props.popularRank!);
}

View File

@ -34,6 +34,7 @@ export interface PanelEditorState {
shouldDiscardChanges: boolean;
isOpen: boolean;
ui: PanelEditorUIState;
isVizPickerOpen: boolean;
}
export const initialState = (): PanelEditorState => {
@ -56,6 +57,7 @@ export const initialState = (): PanelEditorState => {
initDone: false,
shouldDiscardChanges: false,
isOpen: false,
isVizPickerOpen: false,
ui: {
...DEFAULT_PANEL_EDITOR_UI_STATE,
...migratedState,
@ -87,10 +89,22 @@ const pluginsSlice = createSlice({
},
setPanelEditorUIState: (state, action: PayloadAction<Partial<PanelEditorUIState>>) => {
state.ui = { ...state.ui, ...action.payload };
// Close viz picker if closing options pane
if (!state.ui.isPanelOptionsVisible && state.isVizPickerOpen) {
state.isVizPickerOpen = false;
}
},
toggleVizPicker: (state, action: PayloadAction<boolean>) => {
state.isVizPickerOpen = action.payload;
// Ensure options pane is opened when viz picker is open
if (state.isVizPickerOpen) {
state.ui.isPanelOptionsVisible = true;
}
},
closeCompleted: (state) => {
state.isOpen = false;
state.initDone = false;
state.isVizPickerOpen = false;
},
},
});
@ -101,6 +115,7 @@ export const {
setDiscardChanges,
closeCompleted,
setPanelEditorUIState,
toggleVizPicker,
} = pluginsSlice.actions;
export const panelEditorReducer = pluginsSlice.reducer;

View File

@ -1,4 +1,5 @@
import { DataFrame, FieldConfigSource, PanelPlugin } from '@grafana/data';
import { DataFrame, FieldConfigSource, PanelData, PanelPlugin } from '@grafana/data';
import { DashboardModel, PanelModel } from '../../state';
export interface PanelEditorTab {
id: string;
@ -34,3 +35,13 @@ export interface Props {
/* Helpful for IntelliSense */
data: DataFrame[];
}
export interface OptionPaneRenderProps {
panel: PanelModel;
plugin: PanelPlugin;
data?: PanelData;
dashboard: DashboardModel;
onPanelConfigChange: (configKey: string, value: any) => void;
onPanelOptionsChanged: (options: any) => void;
onFieldConfigsChange: (config: FieldConfigSource) => void;
}

View File

@ -0,0 +1,168 @@
import React from 'react';
import { GrafanaTheme, isUnsignedPluginSignature, PanelPluginMeta, PluginState } from '@grafana/data';
import { Badge, BadgeProps, IconButton, PluginSignatureBadge, styleMixins, useStyles } from '@grafana/ui';
import { css, cx } from 'emotion';
import { selectors } from '@grafana/e2e-selectors';
interface Props {
isCurrent: boolean;
plugin: PanelPluginMeta;
title: string;
onClick: () => void;
onDelete?: () => void;
disabled?: boolean;
showBadge?: boolean;
}
export const PanelTypeCard: React.FC<Props> = ({
isCurrent,
title,
plugin,
onClick,
onDelete,
disabled,
showBadge,
}) => {
const styles = useStyles(getStyles);
const cssClass = cx({
[styles.item]: true,
[styles.disabled]: disabled || plugin.state === PluginState.deprecated,
[styles.current]: isCurrent,
});
return (
<div
className={cssClass}
aria-label={selectors.components.PluginVisualization.item(plugin.name)}
onClick={disabled ? undefined : onClick}
title={isCurrent ? 'Click again to close this section' : plugin.name}
>
<img className={styles.img} src={plugin.info.logos.small} />
<div className={styles.itemContent}>
<div className={styles.name}>{title}</div>
</div>
{showBadge && (
<div className={cx(styles.badge, disabled && styles.disabled)}>
<PanelPluginBadge plugin={plugin} />
</div>
)}
{onDelete && (
<IconButton
name="trash-alt"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
/>
)}
</div>
);
};
PanelTypeCard.displayName = 'PanelTypeCard';
const getStyles = (theme: GrafanaTheme) => {
return {
item: css`
position: relative;
display: flex;
flex-shrink: 0;
cursor: pointer;
background: ${theme.colors.bg2};
border: 1px solid ${theme.colors.border2};
border-radius: ${theme.border.radius.sm};
align-items: center;
padding: 8px;
width: 100%;
position: relative;
overflow: hidden;
height: 55px;
&:hover {
background: ${styleMixins.hoverColor(theme.colors.bg2, theme)};
}
`,
itemContent: css`
position: relative;
width: 100%;
`,
current: css`
label: currentVisualizationItem;
border-color: ${theme.colors.bgBlue1};
`,
disabled: css`
opacity: 0.2;
filter: grayscale(1);
cursor: default;
pointer-events: none;
&:hover {
border: 1px solid ${theme.colors.border2};
}
`,
name: css`
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.semibold};
padding: 0 10px;
width: 100%;
`,
img: css`
max-height: 38px;
width: 38px;
display: flex;
align-items: center;
`,
badge: css`
background: ${theme.colors.bg1};
`,
};
};
interface PanelPluginBadgeProps {
plugin: PanelPluginMeta;
}
const PanelPluginBadge: React.FC<PanelPluginBadgeProps> = ({ plugin }) => {
const display = getPanelStateBadgeDisplayModel(plugin);
if (isUnsignedPluginSignature(plugin.signature)) {
return <PluginSignatureBadge status={plugin.signature} />;
}
if (!display) {
return null;
}
return <Badge color={display.color} text={display.text} icon={display.icon} tooltip={display.tooltip} />;
};
function getPanelStateBadgeDisplayModel(panel: PanelPluginMeta): BadgeProps | null {
switch (panel.state) {
case PluginState.deprecated:
return {
text: 'Deprecated',
color: 'red',
tooltip: `${panel.name} panel is deprecated`,
};
case PluginState.alpha:
return {
text: 'Alpha',
color: 'blue',
tooltip: `${panel.name} panel is experimental`,
};
case PluginState.beta:
return {
text: 'Beta',
color: 'blue',
tooltip: `${panel.name} panel is in beta`,
};
default:
return null;
}
}
PanelPluginBadge.displayName = 'PanelPluginBadge';

View File

@ -1,7 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import config from 'app/core/config';
import VizTypePickerPlugin from './VizTypePickerPlugin';
import { VizTypePickerPlugin } from './VizTypePickerPlugin';
import { EmptySearchResult, stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme, PanelPluginMeta, PluginState } from '@grafana/data';
import { css } from 'emotion';
@ -107,8 +107,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
grid: css`
max-width: 100%;
display: grid;
grid-gap: ${theme.spacing.md};
grid-template-columns: repeat(auto-fit, minmax(116px, 1fr));
grid-gap: ${theme.spacing.sm};
grid-template-columns: repeat(auto-fit, minmax(185px, 1fr));
`,
};
});

View File

@ -1,8 +1,6 @@
import React from 'react';
import { GrafanaTheme, isUnsignedPluginSignature, PanelPluginMeta, PluginState } from '@grafana/data';
import { Badge, BadgeProps, PluginSignatureBadge, styleMixins, stylesFactory, useTheme } from '@grafana/ui';
import { css, cx } from 'emotion';
import { selectors } from '@grafana/e2e-selectors';
import { PanelPluginMeta } from '@grafana/data';
import { PanelTypeCard } from './PanelTypeCard';
interface Props {
isCurrent: boolean;
@ -11,165 +9,17 @@ interface Props {
disabled: boolean;
}
const VizTypePickerPlugin: React.FC<Props> = ({ isCurrent, plugin, onClick, disabled }) => {
const theme = useTheme();
const styles = getStyles(theme);
const cssClass = cx({
[styles.item]: true,
[styles.disabled]: disabled || plugin.state === PluginState.deprecated,
[styles.current]: isCurrent,
});
export const VizTypePickerPlugin: React.FC<Props> = ({ isCurrent, plugin, onClick, disabled }) => {
return (
<div className={styles.wrapper} aria-label={selectors.components.PluginVisualization.item(plugin.name)}>
<div
className={cssClass}
onClick={disabled ? () => {} : onClick}
title={isCurrent ? 'Click again to close this section' : plugin.name}
>
<div className={styles.bg} />
<div className={styles.itemContent}>
<div className={styles.name} title={plugin.name}>
{plugin.name}
</div>
<img className={styles.img} src={plugin.info.logos.small} />
</div>
</div>
<div className={cx(styles.badge, disabled && styles.disabled)}>
<PanelPluginBadge plugin={plugin} />
</div>
</div>
<PanelTypeCard
title={plugin.name}
plugin={plugin}
onClick={onClick}
isCurrent={isCurrent}
disabled={disabled}
showBadge={true}
/>
);
};
VizTypePickerPlugin.displayName = 'VizTypePickerPlugin';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
wrapper: css`
position: relative;
`,
bg: css`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: ${theme.colors.bg2};
border: 1px solid ${theme.colors.border2};
border-radius: 3px;
transform: scale(1);
transform-origin: center;
transition: all 0.1s ease-in;
z-index: 0;
`,
item: css`
flex-shrink: 0;
flex-direction: column;
text-align: center;
cursor: pointer;
display: flex;
margin-right: 10px;
align-items: center;
justify-content: center;
padding-bottom: 6px;
height: 100px;
width: 100%;
position: relative;
&:hover {
> div:first-child {
transform: scale(1.05);
border-color: ${theme.colors.formFocusOutline};
}
}
`,
itemContent: css`
position: relative;
z-index: 1;
width: 100%;
`,
current: css`
label: currentVisualizationItem;
> div:first-child {
${styleMixins.focusCss(theme)};
}
`,
disabled: css`
opacity: 0.2;
filter: grayscale(1);
cursor: default;
pointer-events: none;
&:hover {
border: 1px solid ${theme.colors.border2};
}
`,
name: css`
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-size: ${theme.typography.size.sm};
text-align: center;
height: 23px;
font-weight: ${theme.typography.weight.semibold};
padding: 0 10px;
width: 100%;
`,
img: css`
height: 55px;
`,
badge: css`
position: absolute;
background: ${theme.colors.bg1};
bottom: ${theme.spacing.xs};
right: ${theme.spacing.xs};
z-index: 1;
`,
};
});
export default VizTypePickerPlugin;
interface PanelPluginBadgeProps {
plugin: PanelPluginMeta;
}
const PanelPluginBadge: React.FC<PanelPluginBadgeProps> = ({ plugin }) => {
const display = getPanelStateBadgeDisplayModel(plugin);
if (isUnsignedPluginSignature(plugin.signature)) {
return <PluginSignatureBadge status={plugin.signature} />;
}
if (!display) {
return null;
}
return <Badge color={display.color} text={display.text} icon={display.icon} tooltip={display.tooltip} />;
};
function getPanelStateBadgeDisplayModel(panel: PanelPluginMeta): BadgeProps | null {
switch (panel.state) {
case PluginState.deprecated:
return {
text: 'Deprecated',
color: 'red',
tooltip: `${panel.name} panel is deprecated`,
};
case PluginState.alpha:
return {
text: 'Alpha',
color: 'blue',
tooltip: `${panel.name} panel is experimental`,
};
case PluginState.beta:
return {
text: 'Beta',
color: 'blue',
tooltip: `${panel.name} panel is in beta`,
};
default:
return null;
}
}
PanelPluginBadge.displayName = 'PanelPluginBadge';

View File

@ -511,6 +511,17 @@ export class PanelModel implements DataConfigSource {
setProperty(key: keyof this, value: any) {
this[key] = value;
this.hasChanged = true;
// Custom handling of repeat dependent options, handled here as PanelEditor can
// update one key at a time right now
if (key === 'repeat') {
if (this.repeat && !this.repeatDirection) {
this.repeatDirection = 'h';
} else if (!this.repeat) {
delete this.repeatDirection;
delete this.maxPerRow;
}
}
}
replaceVariables(value: string, extraVars: ScopedVars | undefined, format?: string | Function) {

View File

@ -215,8 +215,6 @@ export function RichHistoryQueriesTab(props: Props) {
)}
<div className={styles.filterInput}>
<FilterInput
labelClassName="gf-form--has-input-icon gf-form--grow"
inputClassName="gf-form-input"
placeholder="Search queries"
value={searchInput}
onChange={(value: string) => {

View File

@ -132,8 +132,6 @@ export function RichHistoryStarredTab(props: Props) {
)}
<div className={styles.filterInput}>
<FilterInput
labelClassName="gf-form--has-input-icon gf-form--grow"
inputClassName="gf-form-input"
placeholder="Search queries"
value={searchInput}
onChange={(value: string) => {

View File

@ -1,27 +1,24 @@
import React, { useState } from 'react';
import { Card, Icon, IconButton, Tooltip, useStyles } from '@grafana/ui';
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { PanelPluginMeta } from '@grafana/data';
import { LibraryPanelDTO } from '../../types';
import { config } from '@grafana/runtime';
import { PanelTypeCard } from 'app/features/dashboard/components/VizTypePicker/PanelTypeCard';
import { DeleteLibraryPanelModal } from '../DeleteLibraryPanelModal/DeleteLibraryPanelModal';
export interface LibraryPanelCardProps {
libraryPanel: LibraryPanelDTO;
onClick?: (panel: LibraryPanelDTO) => void;
onClick: (panel: LibraryPanelDTO) => void;
onDelete?: (panel: LibraryPanelDTO) => void;
showSecondaryActions?: boolean;
formatDate?: (dateString: string) => string;
}
export const LibraryPanelCard: React.FC<LibraryPanelCardProps & { children?: JSX.Element | JSX.Element[] }> = ({
libraryPanel,
children,
onClick,
onDelete,
formatDate,
showSecondaryActions,
}) => {
const styles = useStyles(getStyles);
//const styles = useStyles(getStyles);
const [showDeletionModal, setShowDeletionModal] = useState(false);
const onDeletePanel = () => {
@ -29,14 +26,22 @@ export const LibraryPanelCard: React.FC<LibraryPanelCardProps & { children?: JSX
setShowDeletionModal(false);
};
const panelPlugin = config.panels[libraryPanel.model.type] ?? ({} as PanelPluginMeta);
return (
<>
<Card heading={libraryPanel.name} onClick={onClick ? () => onClick(libraryPanel) : undefined}>
<PanelTypeCard
isCurrent={false}
title={libraryPanel.name}
plugin={panelPlugin}
onClick={() => onClick(libraryPanel)}
onDelete={showSecondaryActions ? () => setShowDeletionModal(true) : undefined}
/>
{/* <Card heading={libraryPanel.name} onClick={}>
<Card.Figure>
<Icon className={styles.panelIcon} name="book-open" size="xl" />
</Card.Figure>
<Card.Meta>
<span>Reusable panel</span>
<Tooltip content="Connected dashboards" placement="bottom">
<div className={styles.tooltip}>
<Icon name="apps" className={styles.detailIcon} />
@ -48,11 +53,6 @@ export const LibraryPanelCard: React.FC<LibraryPanelCardProps & { children?: JSX
{libraryPanel.meta.updatedBy.name}
</span>
</Card.Meta>
{/*
Commenting this out as tagging isn't implemented yet.
<Card.Tags>
<TagList className={styles.tagList} tags={['associated panel tag']} />
</Card.Tags> */}
{children && <Card.Actions>{children}</Card.Actions>}
{showSecondaryActions && (
<Card.SecondaryActions>
@ -62,13 +62,9 @@ export const LibraryPanelCard: React.FC<LibraryPanelCardProps & { children?: JSX
tooltipPlacement="bottom"
onClick={() => setShowDeletionModal(true)}
/>
{/*
Commenting this out as panel favoriting hasn't been implemented yet.
<IconButton name="star" tooltip="Favorite panel" tooltipPlacement="bottom" />
*/}
</Card.SecondaryActions>
)}
</Card>
</Card> */}
{showDeletionModal && (
<DeleteLibraryPanelModal
libraryPanel={libraryPanel}
@ -80,19 +76,19 @@ export const LibraryPanelCard: React.FC<LibraryPanelCardProps & { children?: JSX
);
};
const getStyles = (theme: GrafanaTheme) => {
return {
tooltip: css`
display: inline;
`,
detailIcon: css`
margin-right: 0.5ch;
`,
panelIcon: css`
color: ${theme.colors.textWeak};
`,
tagList: css`
align-self: center;
`,
};
};
// const getStyles = (theme: GrafanaTheme) => {
// return {
// tooltip: css`
// display: inline;
// `,
// detailIcon: css`
// margin-right: 0.5ch;
// `,
// panelIcon: css`
// color: ${theme.colors.textWeak};
// `,
// tagList: css`
// align-self: center;
// `,
// };
// };

View File

@ -1,6 +1,6 @@
import { DateTimeInput, GrafanaTheme } from '@grafana/data';
import { stylesFactory, useStyles } from '@grafana/ui';
import { OptionsGroup } from 'app/features/dashboard/components/PanelEditor/OptionsGroup';
import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory';
import { PanelModel } from 'app/features/dashboard/state';
import { css } from 'emotion';
import React from 'react';
@ -14,7 +14,7 @@ export const LibraryPanelInformation: React.FC<Props> = ({ panel, formatDate })
const styles = useStyles(getStyles);
return (
<OptionsGroup title="Reusable panel information" id="Shared Panel Info" key="Shared Panel Info">
<OptionsPaneCategory title="Reusable panel information" id="Shared Panel Info" key="Shared Panel Info">
{panel.libraryPanel.uid && (
<p className={styles.libraryPanelInfo}>
{`Used on ${panel.libraryPanel.meta.connectedDashboards} `}
@ -33,7 +33,7 @@ export const LibraryPanelInformation: React.FC<Props> = ({ panel, formatDate })
{panel.libraryPanel.meta.updatedBy.name}
</p>
)}
</OptionsGroup>
</OptionsPaneCategory>
);
};

View File

@ -1,65 +1,49 @@
import React, { FormEvent, useMemo, useReducer } from 'react';
import React, { useMemo, useReducer } from 'react';
import { useDebounce } from 'react-use';
import { css, cx } from 'emotion';
import { Button, Icon, Input, Pagination, stylesFactory, useStyles } from '@grafana/ui';
import { DateTimeInput, GrafanaTheme, LoadingState } from '@grafana/data';
import { Pagination, stylesFactory, useStyles } from '@grafana/ui';
import { GrafanaTheme, LoadingState } from '@grafana/data';
import { LibraryPanelCard } from '../LibraryPanelCard/LibraryPanelCard';
import { LibraryPanelDTO } from '../../types';
import { changePage, changeSearchString, initialLibraryPanelsViewState, libraryPanelsViewReducer } from './reducer';
import { changePage, initialLibraryPanelsViewState, libraryPanelsViewReducer } from './reducer';
import { asyncDispatcher, deleteLibraryPanel, searchForLibraryPanels } from './actions';
interface LibraryPanelViewProps {
className?: string;
onCreateNewPanel?: () => void;
children?: (panel: LibraryPanelDTO, i: number) => JSX.Element | JSX.Element[];
onClickCard?: (panel: LibraryPanelDTO) => void;
formatDate?: (dateString: DateTimeInput, format?: string) => string;
onClickCard: (panel: LibraryPanelDTO) => void;
showSecondaryActions?: boolean;
currentPanelId?: string;
searchString: string;
}
export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
children,
className,
onCreateNewPanel,
onClickCard,
formatDate,
searchString,
showSecondaryActions,
currentPanelId: currentPanel,
}) => {
const styles = useStyles(getPanelViewStyles);
const [
{ libraryPanels, searchString, page, perPage, numberOfPages, loadingState, currentPanelId },
dispatch,
] = useReducer(libraryPanelsViewReducer, {
...initialLibraryPanelsViewState,
currentPanelId: currentPanel,
});
const [{ libraryPanels, page, perPage, numberOfPages, loadingState, currentPanelId }, dispatch] = useReducer(
libraryPanelsViewReducer,
{
...initialLibraryPanelsViewState,
currentPanelId: currentPanel,
}
);
const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]);
useDebounce(() => asyncDispatch(searchForLibraryPanels({ searchString, page, perPage, currentPanelId })), 300, [
searchString,
page,
asyncDispatch,
]);
const onSearchChange = (event: FormEvent<HTMLInputElement>) =>
asyncDispatch(changeSearchString({ searchString: event.currentTarget.value }));
const onDelete = ({ uid }: LibraryPanelDTO) =>
asyncDispatch(deleteLibraryPanel(uid, { searchString, page, perPage }));
const onPageChange = (page: number) => asyncDispatch(changePage({ page }));
return (
<div className={cx(styles.container, className)}>
<div className={styles.searchHeader}>
<Input
placeholder="Search the panel library"
prefix={<Icon name="search" />}
value={searchString}
autoFocus
onChange={onSearchChange}
/>
{/* <Select placeholder="Filter by" onChange={() => {}} width={35} /> */}
</div>
<div className={styles.libraryPanelList}>
{loadingState === LoadingState.Loading ? (
<p>Loading library panels...</p>
@ -72,11 +56,8 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
libraryPanel={item}
onDelete={onDelete}
onClick={onClickCard}
formatDate={formatDate}
showSecondaryActions={showSecondaryActions}
>
{children?.(item, i)}
</LibraryPanelCard>
/>
))
)}
</div>
@ -90,12 +71,6 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
/>
</div>
) : null}
{onCreateNewPanel && (
<Button icon="plus" className={styles.newPanelButton} onClick={onCreateNewPanel}>
Create a new reusable panel
</Button>
)}
</div>
);
};
@ -106,14 +81,11 @@ const getPanelViewStyles = stylesFactory((theme: GrafanaTheme) => {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: ${theme.spacing.sm};
height: 100%;
overflow-y: auto;
`,
libraryPanelList: css`
display: flex;
overflow-x: auto;
flex-direction: column;
max-width: 100%;
display: grid;
grid-gap: ${theme.spacing.sm};
`,
searchHeader: css`
display: flex;

View File

@ -4,10 +4,15 @@ import { from, merge, of, Subscription, timer } from 'rxjs';
import { catchError, finalize, mapTo, mergeMap, share, takeUntil } from 'rxjs/operators';
import { deleteLibraryPanel as apiDeleteLibraryPanel, getLibraryPanels } from '../../state/api';
import { initialLibraryPanelsViewState, initSearch, LibraryPanelsViewState, searchCompleted } from './reducer';
import { DispatchResult } from '../../types';
import { initialLibraryPanelsViewState, initSearch, searchCompleted } from './reducer';
type SearchArgs = Pick<LibraryPanelsViewState, 'searchString' | 'perPage' | 'page' | 'currentPanelId'>;
type DispatchResult = (dispatch: Dispatch<AnyAction>) => void;
interface SearchArgs {
perPage: number;
page: number;
searchString: string;
currentPanelId?: string;
}
export function searchForLibraryPanels(args: SearchArgs): DispatchResult {
return function (dispatch) {

View File

@ -3,7 +3,6 @@ import { LoadingState } from '@grafana/data';
import { reducerTester } from '../../../../../test/core/redux/reducerTester';
import {
changePage,
changeSearchString,
initialLibraryPanelsViewState,
initSearch,
libraryPanelsViewReducer,
@ -73,18 +72,6 @@ describe('libraryPanelsViewReducer', () => {
});
});
describe('when changeSearchString is dispatched', () => {
it('then the state should be correct', () => {
reducerTester<LibraryPanelsViewState>()
.givenReducer(libraryPanelsViewReducer, { ...initialLibraryPanelsViewState })
.whenActionIsDispatched(changeSearchString({ searchString: 'a search string' }))
.thenStateShouldEqual({
...initialLibraryPanelsViewState,
searchString: 'a search string',
});
});
});
describe('when changePage is dispatched', () => {
it('then the state should be correct', () => {
reducerTester<LibraryPanelsViewState>()

View File

@ -7,7 +7,6 @@ import { AnyAction } from 'redux';
export interface LibraryPanelsViewState {
loadingState: LoadingState;
libraryPanels: LibraryPanelDTO[];
searchString: string;
totalCount: number;
perPage: number;
page: number;
@ -16,11 +15,10 @@ export interface LibraryPanelsViewState {
}
export const initialLibraryPanelsViewState: LibraryPanelsViewState = {
loadingState: LoadingState.NotStarted,
loadingState: LoadingState.Loading,
libraryPanels: [],
searchString: '',
totalCount: 0,
perPage: 10,
perPage: 40,
page: 1,
numberOfPages: 0,
currentPanelId: undefined,
@ -30,9 +28,7 @@ export const initSearch = createAction('libraryPanels/view/initSearch');
export const searchCompleted = createAction<
Omit<LibraryPanelsViewState, 'currentPanelId' | 'searchString' | 'loadingState' | 'numberOfPages'>
>('libraryPanels/view/searchCompleted');
export const changeSearchString = createAction<Pick<LibraryPanelsViewState, 'searchString'>>(
'libraryPanels/view/changeSearchString'
);
export const changePage = createAction<Pick<LibraryPanelsViewState, 'page'>>('libraryPanels/view/changePage');
export const libraryPanelsViewReducer = (state: LibraryPanelsViewState, action: AnyAction) => {
@ -54,10 +50,6 @@ export const libraryPanelsViewReducer = (state: LibraryPanelsViewState, action:
};
}
if (changeSearchString.match(action)) {
return { ...state, searchString: action.payload.searchString };
}
if (changePage.match(action)) {
return { ...state, page: action.payload.page };
}

View File

@ -1,89 +1,69 @@
import React, { useState } from 'react';
import { css } from 'emotion';
import pick from 'lodash/pick';
import omit from 'lodash/omit';
import { GrafanaTheme } from '@grafana/data';
import { Button, stylesFactory, useStyles } from '@grafana/ui';
import { OptionsGroup } from 'app/features/dashboard/components/PanelEditor/OptionsGroup';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { PanelModel } from 'app/features/dashboard/state';
import { AddLibraryPanelModal } from '../AddLibraryPanelModal/AddLibraryPanelModal';
import { LibraryPanelsView } from '../LibraryPanelsView/LibraryPanelsView';
import { PanelQueriesChangedEvent } from 'app/types/events';
import { PanelOptionsChangedEvent, PanelQueriesChangedEvent } from 'app/types/events';
import { LibraryPanelDTO } from '../../types';
import { toPanelModelLibraryPanel } from '../../utils';
import { useDispatch } from 'react-redux';
import { changePanelPlugin } from 'app/features/dashboard/state/actions';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
interface Props {
panel: PanelModel;
dashboard: DashboardModel;
onChange: () => void;
searchQuery: string;
}
export const PanelLibraryOptionsGroup: React.FC<Props> = ({ panel, dashboard, onChange }) => {
export const PanelLibraryOptionsGroup: React.FC<Props> = ({ panel, searchQuery }) => {
const styles = useStyles(getStyles);
const [showingAddPanelModal, setShowingAddPanelModal] = useState(false);
const dashboard = getDashboardSrv().getCurrent();
const dispatch = useDispatch();
const useLibraryPanel = (panelInfo: LibraryPanelDTO) => {
const useLibraryPanel = async (panelInfo: LibraryPanelDTO) => {
const panelTypeChanged = panel.type !== panelInfo.model.type;
if (panelTypeChanged) {
await dispatch(changePanelPlugin(panel, panelInfo.model.type));
}
panel.restoreModel({
...omit(panelInfo.model, 'type'),
...pick(panel, 'gridPos', 'id'),
...panelInfo.model,
gridPos: panel.gridPos,
id: panel.id,
libraryPanel: toPanelModelLibraryPanel(panelInfo),
});
if (panelTypeChanged) {
dispatch(changePanelPlugin(panel, panelInfo.model.type));
}
// Though the panel model has changed, since we're switching to an existing
// library panel, we reset the "hasChanged" state.
panel.hasChanged = false;
panel.refresh();
panel.events.publish(PanelQueriesChangedEvent);
// onChange is called here to force the panel editor to re-render
onChange();
panel.events.publish(new PanelQueriesChangedEvent());
panel.events.publish(new PanelOptionsChangedEvent());
};
const onAddToPanelLibrary = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const onAddToPanelLibrary = () => {
setShowingAddPanelModal(true);
};
return (
<OptionsGroup
renderTitle={(isExpanded) => {
return isExpanded && !panel.libraryPanel ? (
<div className={styles.panelLibraryTitle}>
<span>Panel library</span>
<Button size="sm" onClick={onAddToPanelLibrary}>
Add this panel to the panel library
</Button>
</div>
) : (
'Panel library'
);
}}
id="panel-library"
key="panel-library"
defaultToClosed
>
<LibraryPanelsView
formatDate={(dateString: string) => dashboard.formatDate(dateString, 'L')}
currentPanelId={panel.libraryPanel?.uid}
showSecondaryActions
>
{(panel) => (
<Button variant="secondary" onClick={() => useLibraryPanel(panel)}>
Use instead of current panel
<div className={styles.box}>
{!panel.libraryPanel && (
<div className={styles.addButtonWrapper}>
<Button icon="plus" onClick={onAddToPanelLibrary} variant="secondary" fullWidth>
Add current panel to library
</Button>
)}
</LibraryPanelsView>
</div>
)}
<LibraryPanelsView
currentPanelId={panel.libraryPanel?.uid}
searchString={searchQuery}
onClickCard={useLibraryPanel}
showSecondaryActions
/>
{showingAddPanelModal && (
<AddLibraryPanelModal
panel={panel}
@ -92,12 +72,17 @@ export const PanelLibraryOptionsGroup: React.FC<Props> = ({ panel, dashboard, on
isOpen={showingAddPanelModal}
/>
)}
</OptionsGroup>
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
box: css``,
addButtonWrapper: css`
padding-bottom: ${theme.spacing.md};
text-align: center;
`,
panelLibraryTitle: css`
display: flex;
gap: 10px;

View File

@ -95,13 +95,7 @@ export const ManageDashboards: FC<Props> = memo(({ folder }) => {
<div className={styles.container}>
<div>
<HorizontalGroup justify="space-between">
<FilterInput
labelClassName="gf-form--has-input-icon"
inputClassName="gf-form-input width-20"
value={query.query}
onChange={onQueryChange}
placeholder={'Search dashboards by name'}
/>
<FilterInput value={query.query} onChange={onQueryChange} placeholder={'Search dashboards by name'} />
<DashboardActions isEditor={isEditor} canEdit={hasEditPermissionInFolders || canSave} folderId={folderId} />
</HorizontalGroup>
</div>

View File

@ -98,13 +98,7 @@ export class TeamList extends PureComponent<Props, any> {
<>
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<FilterInput
labelClassName="gf-form--has-input-icon gf-form--grow"
inputClassName="gf-form-input"
placeholder="Search teams"
value={searchQuery}
onChange={this.onSearchQueryChange}
/>
<FilterInput placeholder="Search teams" value={searchQuery} onChange={this.onSearchQueryChange} />
</div>
<div className="page-action-bar__spacer" />

View File

@ -75,13 +75,7 @@ export class TeamMembers extends PureComponent<Props, State> {
<div>
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<FilterInput
labelClassName="gf-form--has-input-icon gf-form--grow"
inputClassName="gf-form-input"
placeholder="Search members"
value={searchMemberQuery}
onChange={this.onSearchQueryChange}
/>
<FilterInput placeholder="Search members" value={searchMemberQuery} onChange={this.onSearchQueryChange} />
</div>
<div className="page-action-bar__spacer" />

View File

@ -42,8 +42,6 @@ exports[`Render should render teams table 1`] = `
className="gf-form gf-form--grow"
>
<FilterInput
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search teams"
value=""
@ -377,8 +375,6 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
className="gf-form gf-form--grow"
>
<FilterInput
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search teams"
value=""
@ -504,8 +500,6 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us
className="gf-form gf-form--grow"
>
<FilterInput
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search teams"
value=""

View File

@ -9,8 +9,6 @@ exports[`Render should render component 1`] = `
className="gf-form gf-form--grow"
>
<FilterInput
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search members"
value=""
@ -103,8 +101,6 @@ exports[`Render should render team members 1`] = `
className="gf-form gf-form--grow"
>
<FilterInput
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search members"
value=""

View File

@ -37,8 +37,6 @@ export class UsersActionBar extends PureComponent<Props> {
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<FilterInput
labelClassName="gf-form--has-input-icon"
inputClassName="gf-form-input width-20"
value={searchQuery}
onChange={setUsersSearchQuery}
placeholder="Search user by login, email or name"

View File

@ -8,8 +8,6 @@ exports[`Render should render component 1`] = `
className="gf-form gf-form--grow"
>
<FilterInput
inputClassName="gf-form-input width-20"
labelClassName="gf-form--has-input-icon"
onChange={[MockFunction]}
placeholder="Search user by login, email or name"
value=""
@ -29,8 +27,6 @@ exports[`Render should render pending invites button 1`] = `
className="gf-form gf-form--grow"
>
<FilterInput
inputClassName="gf-form-input width-20"
labelClassName="gf-form--has-input-icon"
onChange={[MockFunction]}
placeholder="Search user by login, email or name"
value=""
@ -74,8 +70,6 @@ exports[`Render should show external user management button 1`] = `
className="gf-form gf-form--grow"
>
<FilterInput
inputClassName="gf-form-input width-20"
labelClassName="gf-form--has-input-icon"
onChange={[MockFunction]}
placeholder="Search user by login, email or name"
value=""
@ -100,8 +94,6 @@ exports[`Render should show invite button 1`] = `
className="gf-form gf-form--grow"
>
<FilterInput
inputClassName="gf-form-input width-20"
labelClassName="gf-form--has-input-icon"
onChange={[MockFunction]}
placeholder="Search user by login, email or name"
value=""

View File

@ -2,13 +2,16 @@ import { sharedSingleStatPanelChangedHandler } from '@grafana/ui';
import { PanelPlugin } from '@grafana/data';
import { BarGaugePanel } from './BarGaugePanel';
import { BarGaugeOptions, displayModes } from './types';
import { addStandardDataReduceOptions } from '../stat/types';
import { addOrientationOption, addStandardDataReduceOptions, addTextSizeOptions } from '../stat/types';
import { barGaugePanelMigrationHandler } from './BarGaugeMigrations';
export const plugin = new PanelPlugin<BarGaugeOptions>(BarGaugePanel)
.useFieldConfig()
.setPanelOptions((builder) => {
addStandardDataReduceOptions(builder);
addOrientationOption(builder);
addTextSizeOptions(builder);
builder
.addRadio({
path: 'displayMode',

View File

@ -1,13 +1,14 @@
import { PanelPlugin } from '@grafana/data';
import { GaugePanel } from './GaugePanel';
import { GaugeOptions } from './types';
import { addStandardDataReduceOptions } from '../stat/types';
import { addStandardDataReduceOptions, addTextSizeOptions } from '../stat/types';
import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations';
export const plugin = new PanelPlugin<GaugeOptions>(GaugePanel)
.useFieldConfig()
.setPanelOptions((builder) => {
addStandardDataReduceOptions(builder, false);
addStandardDataReduceOptions(builder);
builder
.addBooleanSwitch({
path: 'showThresholdLabels',
@ -21,6 +22,8 @@ export const plugin = new PanelPlugin<GaugeOptions>(GaugePanel)
description: 'Renders the thresholds as an outer bar',
defaultValue: true,
});
addTextSizeOptions(builder);
})
.setPanelChangeHandler(gaugePanelChangedHandler)
.setMigrationHandler(gaugePanelMigrationHandler);

View File

@ -22,7 +22,7 @@ export const plugin = new PanelPlugin<PieChartOptions>(PieChartPanel)
},
})
.setPanelOptions((builder) => {
addStandardDataReduceOptions(builder, false);
addStandardDataReduceOptions(builder);
builder
.addRadio({

View File

@ -1,18 +1,23 @@
import { BigValueTextMode, sharedSingleStatMigrationHandler } from '@grafana/ui';
import { PanelPlugin } from '@grafana/data';
import { addStandardDataReduceOptions, StatPanelOptions } from './types';
import { addOrientationOption, addStandardDataReduceOptions, addTextSizeOptions, StatPanelOptions } from './types';
import { StatPanel } from './StatPanel';
import { statPanelChangedHandler } from './StatMigrations';
export const plugin = new PanelPlugin<StatPanelOptions>(StatPanel)
.useFieldConfig()
.setPanelOptions((builder) => {
const mainCategory = ['Stat styles'];
addStandardDataReduceOptions(builder);
addOrientationOption(builder, mainCategory);
addTextSizeOptions(builder);
builder.addSelect({
path: 'textMode',
name: 'Text mode',
description: 'Control if name and value is displayed or just name',
category: mainCategory,
settings: {
options: [
{ value: BigValueTextMode.Auto, label: 'Auto' },
@ -29,8 +34,8 @@ export const plugin = new PanelPlugin<StatPanelOptions>(StatPanel)
.addRadio({
path: 'colorMode',
name: 'Color mode',
description: 'Color either the value or the background',
defaultValue: 'value',
category: mainCategory,
settings: {
options: [
{ value: 'value', label: 'Value' },
@ -42,6 +47,7 @@ export const plugin = new PanelPlugin<StatPanelOptions>(StatPanel)
path: 'graphMode',
name: 'Graph mode',
description: 'Stat panel graph / sparkline mode',
category: mainCategory,
defaultValue: 'area',
settings: {
options: [
@ -52,9 +58,9 @@ export const plugin = new PanelPlugin<StatPanelOptions>(StatPanel)
})
.addRadio({
path: 'justifyMode',
name: 'Alignment mode',
description: 'Value & title posititioning',
name: 'Text alignment',
defaultValue: 'auto',
category: mainCategory,
settings: {
options: [
{ value: 'auto', label: 'Auto' },

View File

@ -25,10 +25,10 @@ export interface StatPanelOptions extends SingleStatBaseOptions {
export function addStandardDataReduceOptions<T extends SingleStatBaseOptions>(
builder: PanelOptionsEditorBuilder<T>,
includeOrientation = true,
includeFieldMatcher = true,
includeTextSizes = true
includeFieldMatcher = true
) {
const valueOptionsCategory = ['Value options'];
builder.addRadio({
path: 'reduceOptions.values',
name: 'Show',
@ -39,6 +39,7 @@ export function addStandardDataReduceOptions<T extends SingleStatBaseOptions>(
{ value: true, label: 'All values' },
],
},
category: valueOptionsCategory,
defaultValue: false,
});
@ -46,6 +47,7 @@ export function addStandardDataReduceOptions<T extends SingleStatBaseOptions>(
path: 'reduceOptions.limit',
name: 'Limit',
description: 'Max number of rows to display',
category: valueOptionsCategory,
settings: {
placeholder: '5000',
integer: true,
@ -60,6 +62,7 @@ export function addStandardDataReduceOptions<T extends SingleStatBaseOptions>(
path: 'reduceOptions.calcs',
name: 'Calculation',
description: 'Choose a reducer function / calculation',
category: valueOptionsCategory,
editor: standardEditorsRegistry.get('stats-picker').editor as any,
defaultValue: [ReducerID.lastNotNull],
// Hides it when all values mode is on
@ -71,6 +74,7 @@ export function addStandardDataReduceOptions<T extends SingleStatBaseOptions>(
path: 'reduceOptions.fields',
name: 'Fields',
description: 'Select the fields that should be included in the panel',
category: valueOptionsCategory,
settings: {
allowCustomValue: true,
options: [],
@ -94,48 +98,52 @@ export function addStandardDataReduceOptions<T extends SingleStatBaseOptions>(
defaultValue: '',
});
}
if (includeOrientation) {
builder.addRadio({
path: 'orientation',
name: 'Orientation',
description: 'Stacking direction in case of multiple series or fields',
settings: {
options: [
{ value: VizOrientation.Auto, label: 'Auto' },
{ value: VizOrientation.Horizontal, label: 'Horizontal' },
{ value: VizOrientation.Vertical, label: 'Vertical' },
],
},
defaultValue: VizOrientation.Auto,
});
}
if (includeTextSizes) {
builder.addNumberInput({
path: 'text.titleSize',
category: ['Text size'],
name: 'Title',
settings: {
placeholder: 'Auto',
integer: false,
min: 1,
max: 200,
},
defaultValue: undefined,
});
builder.addNumberInput({
path: 'text.valueSize',
category: ['Text size'],
name: 'Value',
settings: {
placeholder: 'Auto',
integer: false,
min: 1,
max: 200,
},
defaultValue: undefined,
});
}
}
export function addOrientationOption<T extends SingleStatBaseOptions>(
builder: PanelOptionsEditorBuilder<T>,
category?: string[]
) {
builder.addRadio({
path: 'orientation',
name: 'Orientation',
description: 'Layout orientation',
category,
settings: {
options: [
{ value: VizOrientation.Auto, label: 'Auto' },
{ value: VizOrientation.Horizontal, label: 'Horizontal' },
{ value: VizOrientation.Vertical, label: 'Vertical' },
],
},
defaultValue: VizOrientation.Auto,
});
}
export function addTextSizeOptions<T extends SingleStatBaseOptions>(builder: PanelOptionsEditorBuilder<T>) {
builder.addNumberInput({
path: 'text.titleSize',
category: ['Text size'],
name: 'Title',
settings: {
placeholder: 'Auto',
integer: false,
min: 1,
max: 200,
},
defaultValue: undefined,
});
builder.addNumberInput({
path: 'text.valueSize',
category: ['Text size'],
name: 'Value',
settings: {
placeholder: 'Auto',
integer: false,
min: 1,
max: 200,
},
defaultValue: undefined,
});
}

View File

@ -42,6 +42,8 @@ export const defaultGraphConfig: GraphFieldConfig = {
};
export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs<GraphFieldConfig> {
const categoryStyles = ['Graph styles'];
return {
standardOptions: {
[FieldConfigProperty.Color]: {
@ -60,6 +62,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
.addRadio({
path: 'drawStyle',
name: 'Style',
category: categoryStyles,
defaultValue: cfg.drawStyle,
settings: {
options: graphFieldOptions.drawStyle,
@ -68,6 +71,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
.addRadio({
path: 'lineInterpolation',
name: 'Line interpolation',
category: categoryStyles,
defaultValue: cfg.lineInterpolation,
settings: {
options: graphFieldOptions.lineInterpolation,
@ -77,6 +81,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
.addRadio({
path: 'barAlignment',
name: 'Bar alignment',
category: categoryStyles,
defaultValue: cfg.barAlignment,
settings: {
options: graphFieldOptions.barAlignment,
@ -86,6 +91,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
.addSliderInput({
path: 'lineWidth',
name: 'Line width',
category: categoryStyles,
defaultValue: cfg.lineWidth,
settings: {
min: 0,
@ -97,6 +103,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
.addSliderInput({
path: 'fillOpacity',
name: 'Fill opacity',
category: categoryStyles,
defaultValue: cfg.fillOpacity,
settings: {
min: 0,
@ -108,6 +115,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
.addRadio({
path: 'gradientMode',
name: 'Gradient mode',
category: categoryStyles,
defaultValue: graphFieldOptions.fillGradient[0].value,
settings: {
options: graphFieldOptions.fillGradient,
@ -118,6 +126,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
id: 'fillBelowTo',
path: 'fillBelowTo',
name: 'Fill below to',
category: categoryStyles,
editor: FillBellowToEditor,
override: FillBellowToEditor,
process: stringOverrideProcessor,
@ -128,6 +137,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
id: 'lineStyle',
path: 'lineStyle',
name: 'Line style',
category: categoryStyles,
showIf: (c) => c.drawStyle === DrawStyle.Line,
editor: LineStyleEditor,
override: LineStyleEditor,
@ -138,6 +148,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
id: 'spanNulls',
path: 'spanNulls',
name: 'Connect null values',
category: categoryStyles,
defaultValue: false,
editor: SpanNullsEditor,
override: SpanNullsEditor,
@ -148,6 +159,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
.addRadio({
path: 'showPoints',
name: 'Show points',
category: categoryStyles,
defaultValue: graphFieldOptions.showPoints[0].value,
settings: {
options: graphFieldOptions.showPoints,
@ -157,6 +169,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
.addSliderInput({
path: 'pointSize',
name: 'Point size',
category: categoryStyles,
defaultValue: 5,
settings: {
min: 1,
@ -266,6 +279,7 @@ export function addLegendOptions<T extends OptionsWithLegend>(builder: PanelOpti
.addRadio({
path: 'legend.displayMode',
name: 'Legend mode',
category: ['Legend'],
description: '',
defaultValue: LegendDisplayMode.List,
settings: {
@ -279,6 +293,7 @@ export function addLegendOptions<T extends OptionsWithLegend>(builder: PanelOpti
.addRadio({
path: 'legend.placement',
name: 'Legend placement',
category: ['Legend'],
description: '',
defaultValue: 'bottom',
settings: {
@ -292,8 +307,9 @@ export function addLegendOptions<T extends OptionsWithLegend>(builder: PanelOpti
.addCustomEditor<StatsPickerConfigSettings, string[]>({
id: 'legend.calcs',
path: 'legend.calcs',
name: 'Legend calculations',
description: 'Choose a reducer functions / calculations to include in legend',
name: 'Legend values',
category: ['Legend'],
description: 'Select values or calculations to show in legend',
editor: standardEditorsRegistry.get('stats-picker').editor as any,
defaultValue: [],
settings: {

View File

@ -12,6 +12,7 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(TimeSeriesPanel
builder.addRadio({
path: 'tooltipOptions.mode',
name: 'Tooltip mode',
category: ['Legend'],
description: '',
defaultValue: 'single',
settings: {

View File

@ -1,7 +1,10 @@
import { identityOverrideProcessor, ThresholdsMode } from '@grafana/data';
export function mockStandardFieldConfigOptions() {
const category = ['Standard options'];
const unit = {
category,
id: 'unit',
path: 'unit',
name: 'Unit',
@ -15,6 +18,7 @@ export function mockStandardFieldConfigOptions() {
};
const decimals = {
category,
id: 'decimals',
path: 'decimals',
name: 'Decimals',
@ -28,6 +32,7 @@ export function mockStandardFieldConfigOptions() {
};
const boolean = {
category,
id: 'boolean',
path: 'boolean',
name: 'Boolean',
@ -41,6 +46,7 @@ export function mockStandardFieldConfigOptions() {
};
const fieldColor = {
category,
id: 'color',
path: 'color',
name: 'color',
@ -54,6 +60,7 @@ export function mockStandardFieldConfigOptions() {
};
const text = {
category,
id: 'text',
path: 'text',
name: 'text',
@ -67,6 +74,7 @@ export function mockStandardFieldConfigOptions() {
};
const number = {
category,
id: 'number',
path: 'number',
name: 'number',
@ -80,6 +88,7 @@ export function mockStandardFieldConfigOptions() {
};
const thresholds = {
category: ['Thresholds'],
id: 'thresholds',
path: 'thresholds',
name: 'thresholds',