mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
ea186947d2
commit
9d6c8f8512
@ -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');
|
||||
},
|
||||
});
|
||||
|
@ -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[];
|
||||
|
@ -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'> {
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -208,6 +208,11 @@ export function getPropertiesForVariant(theme: GrafanaTheme, variant: ButtonVari
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colors.linkExternal};
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
}}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -17,7 +17,6 @@ function setupTestContext(options: Partial<DataLinksListItemProps>) {
|
||||
onChange: jest.fn(),
|
||||
onEdit: jest.fn(),
|
||||
onRemove: jest.fn(),
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
const props = { ...defaults, ...options };
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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) : [])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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 (
|
||||
|
@ -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;
|
||||
|
@ -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];
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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=""
|
||||
|
@ -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>
|
||||
|
@ -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" />
|
||||
|
@ -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}>
|
||||
|
@ -114,6 +114,7 @@ export class AngularPanelOptionsUnconnected extends PureComponent<Props> {
|
||||
};
|
||||
|
||||
this.angularOptions = loader.load(this.element, scopeProps, template);
|
||||
this.angularOptions.digest();
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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 = (
|
||||
|
@ -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}]`;
|
@ -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;
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
@ -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}]`;
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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>;
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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};
|
||||
`,
|
||||
});
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
|
@ -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;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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}</>;
|
||||
};
|
@ -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
|
||||
);
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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
|
||||
);
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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(),
|
||||
})
|
||||
),
|
||||
];
|
||||
}
|
@ -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;
|
||||
}
|
@ -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!);
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
@ -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));
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
@ -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';
|
||||
|
@ -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) {
|
||||
|
@ -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) => {
|
||||
|
@ -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) => {
|
||||
|
@ -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;
|
||||
// `,
|
||||
// };
|
||||
// };
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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>()
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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" />
|
||||
|
@ -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" />
|
||||
|
@ -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=""
|
||||
|
@ -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=""
|
||||
|
@ -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"
|
||||
|
@ -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=""
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
|
@ -22,7 +22,7 @@ export const plugin = new PanelPlugin<PieChartOptions>(PieChartPanel)
|
||||
},
|
||||
})
|
||||
.setPanelOptions((builder) => {
|
||||
addStandardDataReduceOptions(builder, false);
|
||||
addStandardDataReduceOptions(builder);
|
||||
|
||||
builder
|
||||
.addRadio({
|
||||
|
@ -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' },
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user