Merge remote-tracking branch 'origin/develop' into 14409/threshold-ux-changes

This commit is contained in:
Peter Holmberg 2018-12-13 11:19:16 +01:00
commit c78d5fb24e
47 changed files with 1331 additions and 775 deletions

View File

@ -8,15 +8,16 @@ interface ExtendedOptionProps extends OptionProps<any> {
} }
export const Option = (props: ExtendedOptionProps) => { export const Option = (props: ExtendedOptionProps) => {
const { children, isSelected, data, className } = props; const { children, isSelected, data } = props;
return ( return (
<components.Option {...props}> <components.Option {...props}>
<div className={`description-picker-option__button btn btn-link ${className}`}> <div className="gf-form-select-box__desc-option">
{isSelected && <i className="fa fa-check pull-right" aria-hidden="true" />} <div className="gf-form-select-box__desc-option__body">
<div className="gf-form">{children}</div> <div>{children}</div>
<div className="gf-form"> {data.description && <div className="gf-form-select-box__desc-option__desc">{data.description}</div>}
<div className="muted width-17">{data.description}</div>
</div> </div>
{isSelected && <i className="fa fa-check" aria-hidden="true" />}
</div> </div>
</components.Option> </components.Option>
); );

View File

@ -1,4 +1,4 @@
import React from 'react'; import React from 'react';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import PickerOption from './PickerOption'; import PickerOption from './PickerOption';
@ -24,7 +24,7 @@ const model = {
children: 'Model title', children: 'Model title',
data: { data: {
title: 'Model title', title: 'Model title',
avatarUrl: 'url/to/avatar', imgUrl: 'url/to/avatar',
label: 'User picker label', label: 'User picker label',
}, },
className: 'class-for-user-picker', className: 'class-for-user-picker',

View File

@ -4,19 +4,41 @@ import { OptionProps } from 'react-select/lib/components/Option';
// https://github.com/JedWatson/react-select/issues/3038 // https://github.com/JedWatson/react-select/issues/3038
interface ExtendedOptionProps extends OptionProps<any> { interface ExtendedOptionProps extends OptionProps<any> {
data: any; data: {
description?: string;
imgUrl?: string;
};
} }
export const PickerOption = (props: ExtendedOptionProps) => { export const Option = (props: ExtendedOptionProps) => {
const { children, data, className } = props; const { children, isSelected, data } = props;
return ( return (
<components.Option {...props}> <components.Option {...props}>
<div className={`description-picker-option__button btn btn-link ${className}`}> <div className="gf-form-select-box__desc-option">
{data.avatarUrl && <img src={data.avatarUrl} alt={data.label} className="user-picker-option__avatar" />} {data.imgUrl && <img className="gf-form-select-box__desc-option__img" src={data.imgUrl} />}
{children} <div className="gf-form-select-box__desc-option__body">
<div>{children}</div>
{data.description && <div className="gf-form-select-box__desc-option__desc">{data.description}</div>}
</div>
{isSelected && <i className="fa fa-check" aria-hidden="true" />}
</div> </div>
</components.Option> </components.Option>
); );
}; };
export default PickerOption; // was not able to type this without typescript error
export const SingleValue = props => {
const { children, data } = props;
return (
<components.SingleValue {...props}>
<div className="gf-form-select-box__img-value">
{data.imgUrl && <img className="gf-form-select-box__desc-option__img" src={data.imgUrl} />}
{children}
</div>
</components.SingleValue>
);
};
export default Option;

View File

@ -0,0 +1,60 @@
// import React, { PureComponent } from 'react';
// import Select as ReactSelect from 'react-select';
// import DescriptionOption from './DescriptionOption';
// import IndicatorsContainer from './IndicatorsContainer';
// import ResetStyles from './ResetStyles';
//
// export interface OptionType {
// label: string;
// value: string;
// }
//
// interface Props {
// defaultValue?: any;
// getOptionLabel: (item: T) => string;
// getOptionValue: (item: T) => string;
// onChange: (item: T) => {} | void;
// options: T[];
// placeholder?: string;
// width?: number;
// value: T;
// className?: string;
// }
//
// export class Select<T> extends PureComponent<Props<T>> {
// static defaultProps = {
// width: null,
// className: '',
// }
//
// render() {
// const { defaultValue, getOptionLabel, getOptionValue, onSelected, options, placeholder, width, value, className } = this.props;
// let widthClass = '';
// if (width) {
// widthClass = 'width-'+width;
// }
//
// return (
// <ReactSelect
// classNamePrefix="gf-form-select-box"
// className={`gf-form-input gf-form-input--form-dropdown ${widthClass} ${className}`}
// components={{
// Option: DescriptionOption,
// IndicatorsContainer,
// }}
// defaultValue={defaultValue}
// value={value}
// getOptionLabel={getOptionLabel}
// getOptionValue={getOptionValue}
// menuShouldScrollIntoView={false}
// isSearchable={false}
// onChange={onSelected}
// options={options}
// placeholder={placeholder || 'Choose'}
// styles={ResetStyles}
// />
// );
// }
// }
//
// export default Select;

View File

@ -1,6 +1,7 @@
import React, { SFC } from 'react'; import React, { SFC } from 'react';
import Select from 'react-select'; import Select from 'react-select';
import DescriptionOption from './DescriptionOption'; import DescriptionOption from './DescriptionOption';
import IndicatorsContainer from './IndicatorsContainer';
import ResetStyles from './ResetStyles'; import ResetStyles from './ResetStyles';
interface Props { interface Props {
@ -11,7 +12,7 @@ interface Props {
onSelected: (item: any) => {} | void; onSelected: (item: any) => {} | void;
options: any[]; options: any[];
placeholder?: string; placeholder?: string;
width: number; width?: number;
value: any; value: any;
} }
@ -28,10 +29,11 @@ const SimplePicker: SFC<Props> = ({
}) => { }) => {
return ( return (
<Select <Select
classNamePrefix={`gf-form-select-box`} classNamePrefix="gf-form-select-box"
className={`width-${width} gf-form-input gf-form-input--form-dropdown ${className || ''}`} className={`${width ? 'width-' + width : ''} gf-form-input gf-form-input--form-dropdown ${className || ''}`}
components={{ components={{
Option: DescriptionOption, Option: DescriptionOption,
IndicatorsContainer,
}} }}
defaultValue={defaultValue} defaultValue={defaultValue}
value={value} value={value}

View File

@ -47,7 +47,7 @@ export class TeamPicker extends Component<Props, State> {
id: team.id, id: team.id,
label: team.name, label: team.name,
name: team.name, name: team.name,
avatarUrl: team.avatarUrl, imgUrl: team.avatarUrl,
}; };
}); });

View File

@ -63,7 +63,7 @@ export default class UnitPicker extends PureComponent<Props> {
return ( return (
<Select <Select
classNamePrefix="gf-form-select-box" classNamePrefix="gf-form-select-box"
className={`width-${width} gf-form-input--form-dropdown`} className={`width-${width} gf-form-input gf-form-input--form-dropdown`}
defaultValue={value} defaultValue={value}
isSearchable={true} isSearchable={true}
menuShouldScrollIntoView={false} menuShouldScrollIntoView={false}

View File

@ -41,7 +41,7 @@ export class UserPicker extends Component<Props, State> {
return result.map(user => ({ return result.map(user => ({
id: user.userId, id: user.userId,
label: user.login === user.email ? user.login : `${user.login} - ${user.email}`, label: user.login === user.email ? user.login : `${user.login} - ${user.email}`,
avatarUrl: user.avatarUrl, imgUrl: user.avatarUrl,
login: user.login, login: user.login,
})); }));
}) })

View File

@ -3,15 +3,19 @@
exports[`PickerOption renders correctly 1`] = ` exports[`PickerOption renders correctly 1`] = `
<div> <div>
<div <div
className="description-picker-option__button btn btn-link class-for-user-picker" className="gf-form-select-box__desc-option"
> >
<img <img
alt="User picker label" className="gf-form-select-box__desc-option__img"
className="user-picker-option__avatar"
src="url/to/avatar" src="url/to/avatar"
/> />
Model title <div
className="gf-form-select-box__desc-option__body"
>
<div>
Model title
</div>
</div>
</div> </div>
</div> </div>
`; `;

View File

@ -141,9 +141,10 @@ export class DashboardMigrator {
// ensure query refIds // ensure query refIds
panelUpgrades.push(panel => { panelUpgrades.push(panel => {
console.log('asdasd', panel);
_.each(panel.targets, target => { _.each(panel.targets, target => {
if (!target.refId) { if (!target.refId) {
target.refId = this.dashboard.getNextQueryLetter(panel); target.refId = panel.getNextQueryLetter && panel.getNextQueryLetter();
} }
}); });
}); });

View File

@ -806,16 +806,6 @@ export class DashboardModel {
return this.timezone === 'browser' ? moment(date).fromNow() : moment.utc(date).fromNow(); return this.timezone === 'browser' ? moment(date).fromNow() : moment.utc(date).fromNow();
} }
getNextQueryLetter(panel) {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return _.find(letters, refId => {
return _.every(panel.targets, other => {
return other.refId !== refId;
});
});
}
isTimezoneUtc() { isTimezoneUtc() {
return this.getTimezone() === 'utc'; return this.getTimezone() === 'utc';
} }

View File

@ -1,115 +1,81 @@
// Libraries
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import classNames from 'classnames';
import _ from 'lodash'; import _ from 'lodash';
import KeyboardNavigation, { KeyboardNavigationProps } from './KeyboardNavigation';
// Components
import ResetStyles from 'app/core/components/Picker/ResetStyles';
import { Option, SingleValue } from 'app/core/components/Picker/PickerOption';
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
import Select from 'react-select';
// Types
import { DataSourceSelectItem } from 'app/types'; import { DataSourceSelectItem } from 'app/types';
export interface Props { export interface Props {
onChangeDataSource: (ds: DataSourceSelectItem) => void; onChangeDataSource: (ds: DataSourceSelectItem) => void;
datasources: DataSourceSelectItem[]; datasources: DataSourceSelectItem[];
current: DataSourceSelectItem;
onBlur?: () => void;
autoFocus?: boolean;
} }
interface State { export class DataSourcePicker extends PureComponent<Props> {
searchQuery: string; static defaultProps = {
} autoFocus: false,
};
export class DataSourcePicker extends PureComponent<Props, State> {
searchInput: HTMLElement; searchInput: HTMLElement;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {
searchQuery: '',
};
} }
getDataSources() { onChange = item => {
const { searchQuery } = this.state; const ds = this.props.datasources.find(ds => ds.name === item.value);
const regex = new RegExp(searchQuery, 'i'); this.props.onChangeDataSource(ds);
const { datasources } = this.props;
const filtered = datasources.filter(item => {
return regex.test(item.name) || regex.test(item.meta.name);
});
return filtered;
}
get maxSelectedIndex() {
const filtered = this.getDataSources();
return filtered.length - 1;
}
renderDataSource = (ds: DataSourceSelectItem, index: number, keyNavProps: KeyboardNavigationProps) => {
const { onChangeDataSource } = this.props;
const { selected, onMouseEnter } = keyNavProps;
const onClick = () => onChangeDataSource(ds);
const isSelected = selected === index;
const cssClass = classNames({
'ds-picker-list__item': true,
'ds-picker-list__item--selected': isSelected,
});
return (
<div key={index} className={cssClass} title={ds.name} onClick={onClick} onMouseEnter={() => onMouseEnter(index)}>
<img className="ds-picker-list__img" src={ds.meta.info.logos.small} />
<div className="ds-picker-list__name">{ds.name}</div>
</div>
);
}; };
componentDidMount() {
setTimeout(() => {
this.searchInput.focus();
}, 300);
}
onSearchQueryChange = evt => {
const value = evt.target.value;
this.setState(prevState => ({
...prevState,
searchQuery: value,
}));
};
renderFilters({ onKeyDown, selected }: KeyboardNavigationProps) {
const { searchQuery } = this.state;
return (
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-13"
placeholder=""
ref={elem => (this.searchInput = elem)}
onChange={this.onSearchQueryChange}
value={searchQuery}
onKeyDown={evt => {
onKeyDown(evt, this.maxSelectedIndex, () => {
const { onChangeDataSource } = this.props;
const ds = this.getDataSources()[selected];
onChangeDataSource(ds);
});
}}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
);
}
render() { render() {
const { datasources, current, autoFocus, onBlur } = this.props;
const options = datasources.map(ds => ({
value: ds.name,
label: ds.name,
imgUrl: ds.meta.info.logos.small,
}));
const value = current && {
label: current.name,
value: current.name,
imgUrl: current.meta.info.logos.small,
};
return ( return (
<KeyboardNavigation <div className="gf-form-inline">
render={(keyNavProps: KeyboardNavigationProps) => ( <Select
<> classNamePrefix={`gf-form-select-box`}
<div className="cta-form__bar"> isMulti={false}
{this.renderFilters(keyNavProps)} menuShouldScrollIntoView={false}
<div className="gf-form--grow" /> isClearable={false}
</div> className="gf-form-input gf-form-input--form-dropdown ds-picker"
<div className="ds-picker-list"> onChange={item => this.onChange(item)}
{this.getDataSources().map((ds, index) => this.renderDataSource(ds, index, keyNavProps))} options={options}
</div> styles={ResetStyles}
</> autoFocus={autoFocus}
)} onBlur={onBlur}
/> openMenuOnFocus={true}
maxMenuHeight={500}
placeholder="Select datasource"
loadingMessage={() => 'Loading datasources...'}
noOptionsMessage={() => 'No datasources found'}
value={value}
components={{
Option,
SingleValue,
IndicatorsContainer,
}}
/>
</div>
); );
} }
} }

View File

@ -5,8 +5,8 @@ import { FadeIn } from 'app/core/components/Animations/FadeIn';
interface Props { interface Props {
children: JSX.Element; children: JSX.Element;
heading: string; heading: string;
main?: EditorToolBarView; renderToolbar?: () => JSX.Element;
toolbarItems: EditorToolBarView[]; toolbarItems?: EditorToolBarView[];
} }
export interface EditorToolBarView { export interface EditorToolBarView {
@ -15,7 +15,7 @@ export interface EditorToolBarView {
icon?: string; icon?: string;
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
render: (closeFunction: any) => JSX.Element | JSX.Element[]; render: (closeFunction?: any) => JSX.Element | JSX.Element[];
} }
interface State { interface State {
@ -25,6 +25,10 @@ interface State {
} }
export class EditorTabBody extends PureComponent<Props, State> { export class EditorTabBody extends PureComponent<Props, State> {
static defaultProps = {
toolbarItems: [],
};
constructor(props) { constructor(props) {
super(props); super(props);
@ -65,16 +69,6 @@ export class EditorTabBody extends PureComponent<Props, State> {
return state; return state;
} }
renderMainSelection(view: EditorToolBarView) {
return (
<div className="toolbar__main" onClick={() => this.onToggleToolBarView(view)} key={view.title + view.icon}>
<img className="toolbar__main-image" src={view.imgSrc} />
<div className="toolbar__main-name">{view.title}</div>
<i className="fa fa-caret-down" />
</div>
);
}
renderButton(view: EditorToolBarView) { renderButton(view: EditorToolBarView) {
const onClick = () => { const onClick = () => {
if (view.onClick) { if (view.onClick) {
@ -104,16 +98,20 @@ export class EditorTabBody extends PureComponent<Props, State> {
} }
render() { render() {
const { children, toolbarItems, main, heading } = this.props; const { children, renderToolbar, heading, toolbarItems } = this.props;
const { openView, fadeIn, isOpen } = this.state; const { openView, fadeIn, isOpen } = this.state;
return ( return (
<> <>
<div className="toolbar"> <div className="toolbar">
<div className="toolbar__heading">{heading}</div> <div className="toolbar__heading">{heading}</div>
{main && this.renderMainSelection(main)} {renderToolbar && renderToolbar()}
<div className="gf-form--grow" /> {toolbarItems.length > 0 && (
{toolbarItems.map(item => this.renderButton(item))} <>
<div className="gf-form--grow" />
{toolbarItems.map(item => this.renderButton(item))}
</>
)}
</div> </div>
<div className="panel-editor__scroll"> <div className="panel-editor__scroll">
<CustomScrollbar autoHide={false}> <CustomScrollbar autoHide={false}>

View File

@ -1,36 +1,39 @@
// Libraries
import React, { SFC, PureComponent } from 'react'; import React, { SFC, PureComponent } from 'react';
import Remarkable from 'remarkable';
import _ from 'lodash';
// Components
import DataSourceOption from './DataSourceOption'; import DataSourceOption from './DataSourceOption';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import { EditorTabBody } from './EditorTabBody'; import { EditorTabBody } from './EditorTabBody';
import { DataSourcePicker } from './DataSourcePicker'; import { DataSourcePicker } from './DataSourcePicker';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import './../../panel/metrics_tab';
import config from 'app/core/config';
import { QueryInspector } from './QueryInspector'; import { QueryInspector } from './QueryInspector';
import { TimeRangeOptions } from './TimeRangeOptions'; import { TimeRangeOptions } from './TimeRangeOptions';
import './../../panel/metrics_tab';
import { AngularQueryComponentScope } from 'app/features/panel/metrics_tab';
// Services // Services
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv'; import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
import { DataSourceSelectItem } from 'app/types'; import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import config from 'app/core/config';
import Remarkable from 'remarkable'; // Types
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { DataSourceSelectItem, DataQuery } from 'app/types';
interface Props { interface Props {
panel: PanelModel; panel: PanelModel;
dashboard: DashboardModel; dashboard: DashboardModel;
} }
interface Help {
isLoading: boolean;
helpHtml: any;
}
interface State { interface State {
currentDatasource: DataSourceSelectItem; currentDS: DataSourceSelectItem;
help: Help; helpContent: JSX.Element;
hideTimeOverride: boolean; isLoadingHelp: boolean;
isPickerOpen: boolean;
isAddingMixed: boolean;
} }
interface LoadingPlaceholderProps { interface LoadingPlaceholderProps {
@ -40,7 +43,7 @@ interface LoadingPlaceholderProps {
const LoadingPlaceholder: SFC<LoadingPlaceholderProps> = ({ text }) => <h2>{text}</h2>; const LoadingPlaceholder: SFC<LoadingPlaceholderProps> = ({ text }) => <h2>{text}</h2>;
export class QueriesTab extends PureComponent<Props, State> { export class QueriesTab extends PureComponent<Props, State> {
element: any; element: HTMLElement;
component: AngularComponent; component: AngularComponent;
datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources(); datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
backendSrv: BackendSrv = getBackendSrv(); backendSrv: BackendSrv = getBackendSrv();
@ -50,12 +53,26 @@ export class QueriesTab extends PureComponent<Props, State> {
const { panel } = props; const { panel } = props;
this.state = { this.state = {
currentDatasource: this.datasources.find(datasource => datasource.value === panel.datasource), currentDS: this.datasources.find(datasource => datasource.value === panel.datasource),
help: { isLoadingHelp: false,
isLoading: false, helpContent: null,
helpHtml: null, isPickerOpen: false,
}, isAddingMixed: false,
hideTimeOverride: false, };
}
getAngularQueryComponentScope(): AngularQueryComponentScope {
const { panel, dashboard } = this.props;
return {
panel: panel,
dashboard: dashboard,
refresh: () => panel.refresh(),
render: () => panel.render,
addQuery: this.onAddQuery,
moveQuery: this.onMoveQuery,
removeQuery: this.onRemoveQuery,
events: panel.events,
}; };
} }
@ -64,15 +81,10 @@ export class QueriesTab extends PureComponent<Props, State> {
return; return;
} }
const { panel, dashboard } = this.props;
const loader = getAngularLoader(); const loader = getAngularLoader();
const template = '<metrics-tab />'; const template = '<metrics-tab />';
const scopeProps = { const scopeProps = {
ctrl: { ctrl: this.getAngularQueryComponentScope(),
panel: panel,
dashboard: dashboard,
refresh: () => panel.refresh(),
},
}; };
this.component = loader.load(this.element, scopeProps, template); this.component = loader.load(this.element, scopeProps, template);
@ -86,7 +98,8 @@ export class QueriesTab extends PureComponent<Props, State> {
onChangeDataSource = datasource => { onChangeDataSource = datasource => {
const { panel } = this.props; const { panel } = this.props;
const { currentDatasource } = this.state; const { currentDS } = this.state;
// switching to mixed // switching to mixed
if (datasource.meta.mixed) { if (datasource.meta.mixed) {
panel.targets.forEach(target => { panel.targets.forEach(target => {
@ -95,62 +108,58 @@ export class QueriesTab extends PureComponent<Props, State> {
target.datasource = config.defaultDatasource; target.datasource = config.defaultDatasource;
} }
}); });
} else if (currentDatasource && currentDatasource.meta.mixed) { } else if (currentDS) {
panel.targets.forEach(target => { // if switching from mixed
delete target.datasource; if (currentDS.meta.mixed) {
}); for (const target of panel.targets) {
delete target.datasource;
}
} else if (currentDS.meta.id !== datasource.meta.id) {
// we are changing data source type, clear queries
panel.targets = [{ refId: 'A' }];
}
} }
panel.datasource = datasource.value; panel.datasource = datasource.value;
panel.refresh(); panel.refresh();
this.setState(prevState => ({ this.setState({
...prevState, currentDS: datasource,
currentDatasource: datasource, });
}));
}; };
loadHelp = () => { loadHelp = () => {
const { currentDatasource } = this.state; const { currentDS } = this.state;
const hasHelp = currentDatasource.meta.hasQueryHelp; const hasHelp = currentDS.meta.hasQueryHelp;
if (hasHelp) { if (hasHelp) {
this.setState(prevState => ({ this.setState({
...prevState, helpContent: <h3>Loading help...</h3>,
help: { isLoadingHelp: true,
helpHtml: <h2>Loading help...</h2>, });
isLoading: true,
},
}));
this.backendSrv this.backendSrv
.get(`/api/plugins/${currentDatasource.meta.id}/markdown/query_help`) .get(`/api/plugins/${currentDS.meta.id}/markdown/query_help`)
.then(res => { .then(res => {
const md = new Remarkable(); const md = new Remarkable();
const helpHtml = md.render(res); // TODO: Clean out dangerous code? Previous: this.helpHtml = this.$sce.trustAsHtml(md.render(res)); const helpHtml = md.render(res);
this.setState(prevState => ({ this.setState({
...prevState, helpContent: <div className="markdown-html" dangerouslySetInnerHTML={{ __html: helpHtml }} />,
help: { isLoadingHelp: false,
helpHtml: <div className="markdown-html" dangerouslySetInnerHTML={{ __html: helpHtml }} />, });
isLoading: false,
},
}));
}) })
.catch(() => { .catch(() => {
this.setState(prevState => ({ this.setState({
...prevState, helpContent: <h3>'Error occured when loading help'</h3>,
help: { isLoadingHelp: false,
helpHtml: 'Error occured when loading help', });
isLoading: false,
},
}));
}); });
} }
}; };
renderOptions = close => { renderOptions = close => {
const { currentDatasource } = this.state; const { currentDS } = this.state;
const { queryOptions } = currentDatasource.meta; const { queryOptions } = currentDS.meta;
const { panel } = this.props; const { panel } = this.props;
const onChangeFn = (panelKey: string) => { const onChangeFn = (panelKey: string) => {
@ -223,26 +232,83 @@ export class QueriesTab extends PureComponent<Props, State> {
}; };
renderHelp = () => { renderHelp = () => {
const { helpHtml, isLoading } = this.state.help; const { helpContent, isLoadingHelp } = this.state;
return isLoading ? <LoadingPlaceholder text="Loading help..." /> : helpHtml; return isLoadingHelp ? <LoadingPlaceholder text="Loading help..." /> : helpContent;
};
onAddQuery = (query?: Partial<DataQuery>) => {
this.props.panel.addQuery(query);
this.forceUpdate();
};
onAddQueryClick = () => {
if (this.state.currentDS.meta.mixed) {
this.setState({ isAddingMixed: true });
return;
}
this.props.panel.addQuery();
this.component.digest();
this.forceUpdate();
};
onRemoveQuery = (query: DataQuery) => {
const { panel } = this.props;
const index = _.indexOf(panel.targets, query);
panel.targets.splice(index, 1);
panel.refresh();
this.forceUpdate();
};
onMoveQuery = (query: DataQuery, direction: number) => {
const { panel } = this.props;
const index = _.indexOf(panel.targets, query);
_.move(panel.targets, index, index + direction);
this.forceUpdate();
};
renderToolbar = () => {
const { currentDS } = this.state;
return (
<DataSourcePicker
datasources={this.datasources}
onChangeDataSource={this.onChangeDataSource}
current={currentDS}
/>
);
};
renderMixedPicker = () => {
return (
<DataSourcePicker
datasources={this.datasources}
onChangeDataSource={this.onAddMixedQuery}
current={null}
autoFocus={true}
onBlur={this.onMixedPickerBlur}
/>
);
};
onAddMixedQuery = datasource => {
this.onAddQuery({ datasource: datasource.name });
this.component.digest();
this.setState({ isAddingMixed: false });
};
onMixedPickerBlur = () => {
this.setState({ isAddingMixed: false });
}; };
render() { render() {
const { currentDatasource } = this.state; const { panel } = this.props;
const { hasQueryHelp } = currentDatasource.meta; const { currentDS, isAddingMixed } = this.state;
const dsInformation = { const { hasQueryHelp } = currentDS.meta;
title: currentDatasource.name,
imgSrc: currentDatasource.meta.info.logos.small,
render: closeOpenView => (
<DataSourcePicker
datasources={this.datasources}
onChangeDataSource={ds => {
closeOpenView();
this.onChangeDataSource(ds);
}}
/>
),
};
const queryInspector = { const queryInspector = {
title: 'Query Inspector', title: 'Query Inspector',
@ -265,10 +331,31 @@ export class QueriesTab extends PureComponent<Props, State> {
}; };
return ( return (
<EditorTabBody heading="Queries" main={dsInformation} toolbarItems={[options, queryInspector, dsHelp]}> <EditorTabBody
<> heading="Queries"
<div ref={element => (this.element = element)} style={{ width: '100%' }} /> renderToolbar={this.renderToolbar}
</> toolbarItems={[options, queryInspector, dsHelp]}
>
<div className="query-editor-rows gf-form-group">
<div ref={element => (this.element = element)} />
<div className="gf-form-query">
<div className="gf-form gf-form-query-letter-cell">
<label className="gf-form-label">
<span className="gf-form-query-letter-cell-carret muted">
<i className="fa fa-caret-down" />
</span>
<span className="gf-form-query-letter-cell-letter">{panel.getNextQueryLetter()}</span>
</label>
{!isAddingMixed && (
<button className="btn btn-secondary gf-form-btn" onClick={this.onAddQueryClick}>
Add Query
</button>
)}
{isAddingMixed && this.renderMixedPicker()}
</div>
</div>
</div>
</EditorTabBody> </EditorTabBody>
); );
} }

View File

@ -7,6 +7,7 @@ import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoa
// Components // Components
import { EditorTabBody } from './EditorTabBody'; import { EditorTabBody } from './EditorTabBody';
import { VizTypePicker } from './VizTypePicker'; import { VizTypePicker } from './VizTypePicker';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
// Types // Types
import { PanelModel } from '../panel_model'; import { PanelModel } from '../panel_model';
@ -21,9 +22,24 @@ interface Props {
onTypeChanged: (newType: PanelPlugin) => void; onTypeChanged: (newType: PanelPlugin) => void;
} }
export class VisualizationTab extends PureComponent<Props> { interface State {
isVizPickerOpen: boolean;
searchQuery: string;
}
export class VisualizationTab extends PureComponent<Props, State> {
element: HTMLElement; element: HTMLElement;
angularOptions: AngularComponent; angularOptions: AngularComponent;
searchInput: HTMLElement;
constructor(props) {
super(props);
this.state = {
isVizPickerOpen: false,
searchQuery: '',
};
}
getPanelDefaultOptions = () => { getPanelDefaultOptions = () => {
const { panel, plugin } = this.props; const { panel, plugin } = this.props;
@ -87,10 +103,11 @@ export class VisualizationTab extends PureComponent<Props> {
let template = ''; let template = '';
for (let i = 0; i < panelCtrl.editorTabs.length; i++) { for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
template += ` template +=
<div class="form-section" ng-cloak> `
<div class="form-section__header">{{ctrl.editorTabs[${i}].title}}</div> <div class="form-section" ng-cloak>` +
<div class="form-section__body"> (i > 0 ? `<div class="form-section__header">{{ctrl.editorTabs[${i}].title}}</div>` : '') +
`<div class="form-section__body">
<panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab> <panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab>
</div> </div>
</div> </div>
@ -119,28 +136,81 @@ export class VisualizationTab extends PureComponent<Props> {
this.forceUpdate(); this.forceUpdate();
}; };
onOpenVizPicker = () => {
this.setState({ isVizPickerOpen: true });
};
onCloseVizPicker = () => {
this.setState({ isVizPickerOpen: false });
};
onSearchQueryChange = evt => {
const value = evt.target.value;
this.setState({
searchQuery: value,
});
};
renderToolbar = (): JSX.Element => {
const { plugin } = this.props;
const { searchQuery } = this.state;
if (this.state.isVizPickerOpen) {
return (
<>
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-13"
placeholder=""
onChange={this.onSearchQueryChange}
value={searchQuery}
ref={elem => elem && elem.focus()}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
<div className="flex-grow" />
<button className="btn btn-link" onClick={this.onCloseVizPicker}>
<i className="fa fa-chevron-up" />
</button>
</>
);
} else {
return (
<div className="toolbar__main" onClick={this.onOpenVizPicker}>
<img className="toolbar__main-image" src={plugin.info.logos.small} />
<div className="toolbar__main-name">{plugin.name}</div>
<i className="fa fa-caret-down" />
</div>
);
}
};
onTypeChanged = (plugin: PanelPlugin) => {
if (plugin.id === this.props.plugin.id) {
this.setState({ isVizPickerOpen: false });
} else {
this.props.onTypeChanged(plugin);
}
};
render() { render() {
const { plugin } = this.props; const { plugin } = this.props;
const { isVizPickerOpen, searchQuery } = this.state;
const panelSelection = {
title: plugin.name,
imgSrc: plugin.info.logos.small,
render: () => {
// the needs to be scoped inside this closure
const { plugin, onTypeChanged } = this.props;
return <VizTypePicker current={plugin} onTypeChanged={onTypeChanged} />;
},
};
const panelHelp = {
title: '',
icon: 'fa fa-question',
render: () => <h2>Help</h2>,
};
return ( return (
<EditorTabBody heading="Visualization" main={panelSelection} toolbarItems={[panelHelp]}> <EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar}>
{this.renderPanelOptions()} <>
<FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true}>
<VizTypePicker
current={plugin}
onTypeChanged={this.onTypeChanged}
searchQuery={searchQuery}
onClose={this.onCloseVizPicker}
/>
</FadeIn>
{this.renderPanelOptions()}
</>
</EditorTabBody> </EditorTabBody>
); );
} }

View File

@ -4,27 +4,20 @@ import _ from 'lodash';
import config from 'app/core/config'; import config from 'app/core/config';
import { PanelPlugin } from 'app/types/plugins'; import { PanelPlugin } from 'app/types/plugins';
import VizTypePickerPlugin from './VizTypePickerPlugin'; import VizTypePickerPlugin from './VizTypePickerPlugin';
import KeyboardNavigation, { KeyboardNavigationProps } from './KeyboardNavigation';
export interface Props { export interface Props {
current: PanelPlugin; current: PanelPlugin;
onTypeChanged: (newType: PanelPlugin) => void; onTypeChanged: (newType: PanelPlugin) => void;
}
interface State {
searchQuery: string; searchQuery: string;
onClose: () => void;
} }
export class VizTypePicker extends PureComponent<Props, State> { export class VizTypePicker extends PureComponent<Props> {
searchInput: HTMLElement; searchInput: HTMLElement;
pluginList = this.getPanelPlugins(''); pluginList = this.getPanelPlugins('');
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {
searchQuery: '',
};
} }
get maxSelectedIndex() { get maxSelectedIndex() {
@ -32,12 +25,6 @@ export class VizTypePicker extends PureComponent<Props, State> {
return filteredPluginList.length - 1; return filteredPluginList.length - 1;
} }
componentDidMount() {
setTimeout(() => {
this.searchInput.focus();
}, 300);
}
getPanelPlugins(filter): PanelPlugin[] { getPanelPlugins(filter): PanelPlugin[] {
const panels = _.chain(config.panels) const panels = _.chain(config.panels)
.filter({ hideFromList: false }) .filter({ hideFromList: false })
@ -48,27 +35,22 @@ export class VizTypePicker extends PureComponent<Props, State> {
return _.sortBy(panels, 'sort'); return _.sortBy(panels, 'sort');
} }
renderVizPlugin = (plugin: PanelPlugin, index: number, keyNavProps: KeyboardNavigationProps) => { renderVizPlugin = (plugin: PanelPlugin, index: number) => {
const { onTypeChanged } = this.props; const { onTypeChanged } = this.props;
const { selected, onMouseEnter } = keyNavProps;
const isSelected = selected === index;
const isCurrent = plugin.id === this.props.current.id; const isCurrent = plugin.id === this.props.current.id;
return ( return (
<VizTypePickerPlugin <VizTypePickerPlugin
key={plugin.id} key={plugin.id}
isSelected={isSelected}
isCurrent={isCurrent} isCurrent={isCurrent}
plugin={plugin} plugin={plugin}
onMouseEnter={() => {
onMouseEnter(index);
}}
onClick={() => onTypeChanged(plugin)} onClick={() => onTypeChanged(plugin)}
/> />
); );
}; };
getFilteredPluginList = (): PanelPlugin[] => { getFilteredPluginList = (): PanelPlugin[] => {
const { searchQuery } = this.state; const { searchQuery } = this.props;
const regex = new RegExp(searchQuery, 'i'); const regex = new RegExp(searchQuery, 'i');
const pluginList = this.pluginList; const pluginList = this.pluginList;
@ -79,57 +61,15 @@ export class VizTypePicker extends PureComponent<Props, State> {
return filtered; return filtered;
}; };
onSearchQueryChange = evt => {
const value = evt.target.value;
this.setState(prevState => ({
...prevState,
searchQuery: value,
}));
};
renderFilters = ({ onKeyDown, selected }: KeyboardNavigationProps) => {
const { searchQuery } = this.state;
return (
<>
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-13"
placeholder=""
ref={elem => (this.searchInput = elem)}
onChange={this.onSearchQueryChange}
value={searchQuery}
onKeyDown={evt => {
onKeyDown(evt, this.maxSelectedIndex, () => {
const { onTypeChanged } = this.props;
const vizType = this.getFilteredPluginList()[selected];
onTypeChanged(vizType);
});
}}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
</>
);
};
render() { render() {
const filteredPluginList = this.getFilteredPluginList(); const filteredPluginList = this.getFilteredPluginList();
return ( return (
<KeyboardNavigation <div className="viz-picker">
render={(keyNavProps: KeyboardNavigationProps) => ( <div className="viz-picker-list">
<> {filteredPluginList.map((plugin, index) => this.renderVizPlugin(plugin, index))}
<div className="cta-form__bar"> </div>
{this.renderFilters(keyNavProps)} </div>
<div className="gf-form--grow" />
</div>
<div className="viz-picker">
{filteredPluginList.map((plugin, index) => this.renderVizPlugin(plugin, index, keyNavProps))}
</div>
</>
)}
/>
); );
} }
} }

View File

@ -1,32 +1,29 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { PanelPlugin } from 'app/types/plugins'; import { PanelPlugin } from 'app/types/plugins';
interface Props { interface Props {
isSelected: boolean;
isCurrent: boolean; isCurrent: boolean;
plugin: PanelPlugin; plugin: PanelPlugin;
onClick: () => void; onClick: () => void;
onMouseEnter: () => void;
} }
const VizTypePickerPlugin = React.memo( const VizTypePickerPlugin = React.memo(
({ isSelected, isCurrent, plugin, onClick, onMouseEnter }: Props) => { ({ isCurrent, plugin, onClick }: Props) => {
const cssClass = classNames({ const cssClass = classNames({
'viz-picker__item': true, 'viz-picker__item': true,
'viz-picker__item--selected': isSelected,
'viz-picker__item--current': isCurrent, 'viz-picker__item--current': isCurrent,
}); });
return ( return (
<div className={cssClass} onClick={onClick} title={plugin.name} onMouseEnter={onMouseEnter}> <div className={cssClass} onClick={onClick} title={plugin.name}>
<div className="viz-picker__item-name">{plugin.name}</div> <div className="viz-picker__item-name">{plugin.name}</div>
<img className="viz-picker__item-img" src={plugin.info.logos.small} /> <img className="viz-picker__item-img" src={plugin.info.logos.small} />
</div> </div>
); );
}, },
(prevProps, nextProps) => { (prevProps, nextProps) => {
if (prevProps.isSelected === nextProps.isSelected && prevProps.isCurrent === nextProps.isCurrent) { if (prevProps.isCurrent === nextProps.isCurrent) {
return true; return true;
} }
return false; return false;

View File

@ -1,6 +1,7 @@
import { Emitter } from 'app/core/utils/emitter'; import { Emitter } from 'app/core/utils/emitter';
import _ from 'lodash'; import _ from 'lodash';
import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants'; import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants';
import { DataQuery } from 'app/types';
export interface GridPos { export interface GridPos {
x: number; x: number;
@ -237,6 +238,24 @@ export class PanelModel {
this.restorePanelOptions(pluginId); this.restorePanelOptions(pluginId);
} }
addQuery(query?: Partial<DataQuery>) {
query = query || { refId: 'A' };
query.refId = this.getNextQueryLetter();
query.isNew = true;
this.targets.push(query);
}
getNextQueryLetter(): string {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return _.find(letters, refId => {
return _.every(this.targets, other => {
return other.refId !== refId;
});
});
}
destroy() { destroy() {
this.events.emit('panel-teardown'); this.events.emit('panel-teardown');
this.events.removeAllListeners(); this.events.removeAllListeners();

View File

@ -1,181 +1,24 @@
// Libraries // Libraries
import _ from 'lodash'; import _ from 'lodash';
import Remarkable from 'remarkable';
// Services & utils // Services & utils
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import config from 'app/core/config';
import { Emitter } from 'app/core/utils/emitter'; import { Emitter } from 'app/core/utils/emitter';
// Types // Types
import { DashboardModel } from '../dashboard/dashboard_model'; import { DashboardModel } from '../dashboard/dashboard_model';
import { PanelModel } from '../dashboard/panel_model';
import { DataQuery } from 'app/types';
export class MetricsTabCtrl { export interface AngularQueryComponentScope {
dsName: string; panel: PanelModel;
panel: any;
panelCtrl: any;
datasources: any[];
datasourceInstance: any;
nextRefId: string;
dashboard: DashboardModel; dashboard: DashboardModel;
panelDsValue: any;
addQueryDropdown: any;
queryTroubleshooterOpen: boolean;
helpOpen: boolean;
optionsOpen: boolean;
hasQueryHelp: boolean;
helpHtml: string;
queryOptions: any;
events: Emitter; events: Emitter;
refresh: () => void;
/** @ngInject */ render: () => void;
constructor($scope, private $sce, datasourceSrv, private backendSrv) { removeQuery: (query: DataQuery) => void;
this.panelCtrl = $scope.ctrl; addQuery: (query?: DataQuery) => void;
$scope.ctrl = this; moveQuery: (query: DataQuery, direction: number) => void;
this.panel = this.panelCtrl.panel;
this.panel.datasource = this.panel.datasource || null;
this.panel.targets = this.panel.targets || [{}];
this.dashboard = this.panelCtrl.dashboard;
this.datasources = datasourceSrv.getMetricSources();
this.panelDsValue = this.panelCtrl.panel.datasource;
// added here as old query controller expects this on panelCtrl but
// they are getting MetricsTabCtrl instead
this.events = this.panel.events;
for (const ds of this.datasources) {
if (ds.value === this.panelDsValue) {
this.datasourceInstance = ds;
}
}
this.addQueryDropdown = { text: 'Add Query', value: null, fake: true };
// update next ref id
this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
this.updateDatasourceOptions();
}
updateDatasourceOptions() {
if (this.datasourceInstance) {
this.hasQueryHelp = this.datasourceInstance.meta.hasQueryHelp;
this.queryOptions = this.datasourceInstance.meta.queryOptions;
}
}
getOptions(includeBuiltin) {
return Promise.resolve(
this.datasources
.filter(value => {
return includeBuiltin || !value.meta.builtIn;
})
.map(ds => {
return { value: ds.value, text: ds.name, datasource: ds };
})
);
}
datasourceChanged(option) {
if (!option) {
return;
}
this.setDatasource(option.datasource);
this.updateDatasourceOptions();
}
setDatasource(datasource) {
// switching to mixed
if (datasource.meta.mixed) {
_.each(this.panel.targets, target => {
target.datasource = this.panel.datasource;
if (!target.datasource) {
target.datasource = config.defaultDatasource;
}
});
} else if (this.datasourceInstance) {
// if switching from mixed
if (this.datasourceInstance.meta.mixed) {
_.each(this.panel.targets, target => {
delete target.datasource;
});
} else if (this.datasourceInstance.meta.id !== datasource.meta.id) {
// we are changing data source type, clear queries
this.panel.targets = [{ refId: 'A' }];
this.panelCtrl.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
}
}
this.datasourceInstance = datasource;
this.panel.datasource = datasource.value;
this.panel.refresh();
}
addMixedQuery(option) {
if (!option) {
return;
}
this.panelCtrl.addQuery({
isNew: true,
datasource: option.datasource.name,
});
this.addQueryDropdown = { text: 'Add Query', value: null, fake: true };
}
toggleHelp() {
this.optionsOpen = false;
this.queryTroubleshooterOpen = false;
this.helpOpen = !this.helpOpen;
this.backendSrv.get(`/api/plugins/${this.datasourceInstance.meta.id}/markdown/query_help`).then(res => {
const md = new Remarkable();
this.helpHtml = this.$sce.trustAsHtml(md.render(res));
});
}
toggleOptions() {
this.helpOpen = false;
this.queryTroubleshooterOpen = false;
this.optionsOpen = !this.optionsOpen;
}
toggleQueryTroubleshooter() {
this.helpOpen = false;
this.optionsOpen = false;
this.queryTroubleshooterOpen = !this.queryTroubleshooterOpen;
}
addQuery(query?) {
query = query || {};
query.refId = this.dashboard.getNextQueryLetter(this.panel);
query.isNew = true;
this.panel.targets.push(query);
this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
}
refresh() {
this.panel.refresh();
}
render() {
this.panel.render();
}
removeQuery(target) {
const index = _.indexOf(this.panel.targets, target);
this.panel.targets.splice(index, 1);
this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
this.panel.refresh();
}
moveQuery(target, direction) {
const index = _.indexOf(this.panel.targets, target);
_.move(this.panel.targets, index, index + direction);
}
} }
/** @ngInject */ /** @ngInject */
@ -185,7 +28,6 @@ export function metricsTabDirective() {
restrict: 'E', restrict: 'E',
scope: true, scope: true,
templateUrl: 'public/app/features/panel/partials/metrics_tab.html', templateUrl: 'public/app/features/panel/partials/metrics_tab.html',
controller: MetricsTabCtrl,
}; };
} }

View File

@ -1,5 +1,3 @@
<div class="query-editor-rows gf-form-group" ng-if="ctrl.datasourceInstance">
<div ng-repeat="target in ctrl.panel.targets" ng-class="{'gf-form-disabled': target.hide}"> <div ng-repeat="target in ctrl.panel.targets" ng-class="{'gf-form-disabled': target.hide}">
<rebuild-on-change property="ctrl.panel.datasource || target.datasource" show-null="true"> <rebuild-on-change property="ctrl.panel.datasource || target.datasource" show-null="true">
<plugin-component type="query-ctrl"> <plugin-component type="query-ctrl">
@ -7,21 +5,20 @@
</rebuild-on-change> </rebuild-on-change>
</div> </div>
<div class="gf-form-query"> <!-- <div class="gf&#45;form&#45;query"> -->
<div class="gf-form gf-form-query-letter-cell"> <!-- <div class="gf&#45;form gf&#45;form&#45;query&#45;letter&#45;cell"> -->
<label class="gf-form-label"> <!-- <label class="gf&#45;form&#45;label"> -->
<span class="gf-form-query-letter-cell-carret"> <!-- <span class="gf&#45;form&#45;query&#45;letter&#45;cell&#45;carret"> -->
<i class="fa fa-caret-down"></i> <!-- <i class="fa fa&#45;caret&#45;down"></i> -->
</span> <!-- </span> -->
<span class="gf-form-query-letter-cell-letter">{{ctrl.nextRefId}}</span> <!-- <span class="gf&#45;form&#45;query&#45;letter&#45;cell&#45;letter">{{ctrl.nextRefId}}</span> -->
</label> <!-- </label> -->
<button class="btn btn-secondary gf-form-btn" ng-click="ctrl.addQuery()" ng-hide="ctrl.datasourceInstance.meta.mixed"> <!-- <button class="btn btn&#45;secondary gf&#45;form&#45;btn" ng&#45;click="ctrl.addQuery()" ng&#45;hide="ctrl.datasourceInstance.meta.mixed"> -->
Add Query <!-- Add Query -->
</button> <!-- </button> -->
<div class="dropdown" ng-if="ctrl.datasourceInstance.meta.mixed"> <!-- <div class="dropdown" ng&#45;if="ctrl.datasourceInstance.meta.mixed"> -->
<gf-form-dropdown model="ctrl.addQueryDropdown" get-options="ctrl.getOptions(false)" on-change="ctrl.addMixedQuery($option)"> <!-- <gf&#45;form&#45;dropdown model="ctrl.addQueryDropdown" get&#45;options="ctrl.getOptions(false)" on&#45;change="ctrl.addMixedQuery($option)"> -->
</gf-form-dropdown> <!-- </gf&#45;form&#45;dropdown> -->
</div> <!-- </div> -->
</div> <!-- </div> -->
</div> <!-- </div> -->
</div>

View File

@ -20,7 +20,7 @@ export class QueryRowCtrl {
this.hideEditorRowActions = this.panelCtrl.hideEditorRowActions; this.hideEditorRowActions = this.panelCtrl.hideEditorRowActions;
if (!this.target.refId) { if (!this.target.refId) {
this.target.refId = this.panelCtrl.dashboard.getNextQueryLetter(this.panel); this.target.refId = this.panel.getNextQueryLetter();
} }
this.toggleCollapse(true); this.toggleCollapse(true);

View File

@ -8,7 +8,7 @@
</div> </div>
<div class="gf-form-inline"> <div class="gf-form-inline">
<div class="gf-form max-width-26"> <div class="gf-form">
<label class="gf-form-label width-8">Legend format</label> <label class="gf-form-label width-8">Legend format</label>
<input type="text" class="gf-form-input" ng-model="ctrl.target.legendFormat" spellcheck='false' placeholder="legend format" <input type="text" class="gf-form-input" ng-model="ctrl.target.legendFormat" spellcheck='false' placeholder="legend format"
data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()"> data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()">
@ -58,4 +58,4 @@
<div class="gf-form-label gf-form-label--grow"></div> <div class="gf-form-label gf-form-label--grow"></div>
</div> </div>
</div> </div>
</query-editor-row> </query-editor-row>

View File

@ -0,0 +1,155 @@
import React, { PureComponent } from 'react';
import { Label } from 'app/core/components/Label/Label';
import SimplePicker from 'app/core/components/Picker/SimplePicker';
import { MappingType, RangeMap, ValueMap } from 'app/types';
interface Props {
mapping: ValueMap | RangeMap;
updateMapping: (mapping) => void;
removeMapping: () => void;
}
interface State {
from: string;
id: number;
operator: string;
text: string;
to: string;
type: MappingType;
value: string;
}
const mappingOptions = [
{ value: MappingType.ValueToText, label: 'Value' },
{ value: MappingType.RangeToText, label: 'Range' },
];
export default class MappingRow extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
...props.mapping,
};
}
onMappingValueChange = event => {
this.setState({ value: event.target.value });
};
onMappingFromChange = event => {
this.setState({ from: event.target.value });
};
onMappingToChange = event => {
this.setState({ to: event.target.value });
};
onMappingTextChange = event => {
this.setState({ text: event.target.value });
};
onMappingTypeChange = mappingType => {
this.setState({ type: mappingType });
};
updateMapping = () => {
this.props.updateMapping({ ...this.state });
};
renderRow() {
const { from, text, to, type, value } = this.state;
if (type === MappingType.RangeToText) {
return (
<div className="gf-form">
<div className="gf-form-inline mapping-row-input">
<Label width={4}>From</Label>
<div>
<input
className="gf-form-input"
value={from}
onBlur={this.updateMapping}
onChange={this.onMappingFromChange}
/>
</div>
</div>
<div className="gf-form-inline mapping-row-input">
<Label width={4}>To</Label>
<div>
<input
className="gf-form-input"
value={to}
onBlur={this.updateMapping}
onChange={this.onMappingToChange}
/>
</div>
</div>
<div className="gf-form-inline mapping-row-input">
<Label width={4}>Text</Label>
<div>
<input
className="gf-form-input"
value={text}
onBlur={this.updateMapping}
onChange={this.onMappingTextChange}
/>
</div>
</div>
</div>
);
}
return (
<div className="gf-form">
<div className="gf-form-inline mapping-row-input">
<Label width={4}>Value</Label>
<div>
<input
className="gf-form-input"
onBlur={this.updateMapping}
onChange={this.onMappingValueChange}
value={value}
/>
</div>
</div>
<div className="gf-form-inline mapping-row-input">
<Label width={4}>Text</Label>
<div>
<input
className="gf-form-input"
onBlur={this.updateMapping}
value={text}
onChange={this.onMappingTextChange}
/>
</div>
</div>
</div>
);
}
render() {
const { type } = this.state;
return (
<div className="mapping-row">
<div className="gf-form-inline mapping-row-type">
<Label width={5}>Type</Label>
<SimplePicker
placeholder="Choose type"
options={mappingOptions}
value={mappingOptions.find(o => o.value === type)}
getOptionLabel={i => i.label}
getOptionValue={i => i.value}
onSelected={type => this.onMappingTypeChange(type.value)}
width={7}
/>
</div>
<div>{this.renderRow()}</div>
<div onClick={this.props.removeMapping} className="threshold-row-remove">
<i className="fa fa-times" />
</div>
</div>
);
}
}

View File

@ -1,13 +1,19 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import Thresholds, { BasicGaugeColor } from './Thresholds'; import Thresholds from './Thresholds';
import { OptionsProps } from './module'; import { defaultProps, OptionsProps } from './module';
import { PanelOptionsProps } from '../../../types'; import { PanelOptionsProps } from '../../../types';
const setup = (propOverrides?: object) => { const setup = (propOverrides?: object) => {
const props: PanelOptionsProps<OptionsProps> = { const props: PanelOptionsProps<OptionsProps> = {
onChange: jest.fn(), onChange: jest.fn(),
options: {} as OptionsProps, options: {
...defaultProps.options,
thresholds: [
{ index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
{ index: 1, label: 'Max', value: 100, canRemove: false },
],
},
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
@ -15,12 +21,6 @@ const setup = (propOverrides?: object) => {
return shallow(<Thresholds {...props} />).instance() as Thresholds; return shallow(<Thresholds {...props} />).instance() as Thresholds;
}; };
const thresholds = [
{ index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
{ index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
{ index: 2, label: 'Max', value: 100, canRemove: false, color: BasicGaugeColor.Red },
];
describe('Add threshold', () => { describe('Add threshold', () => {
it('should add threshold between min and max', () => { it('should add threshold between min and max', () => {
const instance = setup(); const instance = setup();
@ -28,24 +28,31 @@ describe('Add threshold', () => {
instance.onAddThreshold(1); instance.onAddThreshold(1);
expect(instance.state.thresholds).toEqual([ expect(instance.state.thresholds).toEqual([
{ index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green }, { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
{ index: 1, label: '', value: 50, canRemove: true, color: 'rgba(131, 123, 52, 0.99)' }, { index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
{ index: 2, label: 'Max', value: 100, canRemove: false, color: BasicGaugeColor.Red }, { index: 2, label: 'Max', value: 100, canRemove: false },
]); ]);
}); });
it('should add threshold between min and added threshold', () => { it('should add threshold between min and added threshold', () => {
const instance = setup({ const instance = setup({
options: { thresholds: thresholds }, options: {
...defaultProps.options,
thresholds: [
{ index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
{ index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
{ index: 2, label: 'Max', value: 100, canRemove: false },
],
},
}); });
instance.onAddThreshold(1); instance.onAddThreshold(1);
expect(instance.state.thresholds).toEqual([ expect(instance.state.thresholds).toEqual([
{ index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green }, { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
{ index: 1, label: '', value: 25, canRemove: true, color: 'rgba(144, 151, 43, 0.93)' }, { index: 1, label: '', value: 25, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
{ index: 2, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' }, { index: 2, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
{ index: 3, label: 'Max', value: 100, canRemove: false, color: BasicGaugeColor.Red }, { index: 3, label: 'Max', value: 100, canRemove: false },
]); ]);
}); });
}); });

View File

@ -3,27 +3,18 @@ import classNames from 'classnames/bind';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { ColorPicker } from 'app/core/components/colorpicker/ColorPicker'; import { ColorPicker } from 'app/core/components/colorpicker/ColorPicker';
import { OptionModuleProps } from './module'; import { OptionModuleProps } from './module';
import { Threshold } from 'app/types'; import { BasicGaugeColor, Threshold } from 'app/types';
interface State { interface State {
thresholds: Threshold[]; thresholds: Threshold[];
} }
export enum BasicGaugeColor {
Green = 'rgba(50, 172, 45, 0.97)',
Orange = 'rgba(237, 129, 40, 0.89)',
Red = 'rgb(212, 74, 58)',
}
export default class Thresholds extends PureComponent<OptionModuleProps, State> { export default class Thresholds extends PureComponent<OptionModuleProps, State> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
thresholds: this.props.options.thresholds || [ thresholds: props.options.thresholds,
{ index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
{ index: 1, label: 'Max', value: 100, canRemove: false, color: BasicGaugeColor.Red },
],
}; };
} }

View File

@ -0,0 +1,73 @@
import React from 'react';
import { shallow } from 'enzyme';
import ValueMappings from './ValueMappings';
import { defaultProps, OptionModuleProps } from './module';
import { MappingType } from 'app/types';
const setup = (propOverrides?: object) => {
const props: OptionModuleProps = {
onChange: jest.fn(),
options: {
...defaultProps.options,
mappings: [
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
],
},
};
Object.assign(props, propOverrides);
const wrapper = shallow(<ValueMappings {...props} />);
const instance = wrapper.instance() as ValueMappings;
return {
instance,
wrapper,
};
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
});
describe('On remove mapping', () => {
it('Should remove mapping with id 0', () => {
const { instance } = setup();
instance.onRemoveMapping(1);
expect(instance.state.mappings).toEqual([
{ id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
]);
});
it('should remove mapping with id 1', () => {
const { instance } = setup();
instance.onRemoveMapping(2);
expect(instance.state.mappings).toEqual([
{ id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
]);
});
});
describe('Next id to add', () => {
it('should be 4', () => {
const { instance } = setup();
instance.addMapping();
expect(instance.state.nextIdToAdd).toEqual(4);
});
it('should default to 1', () => {
const { instance } = setup({ options: { ...defaultProps.options } });
expect(instance.state.nextIdToAdd).toEqual(1);
});
});

View File

@ -0,0 +1,100 @@
import React, { PureComponent } from 'react';
import MappingRow from './MappingRow';
import { OptionModuleProps } from './module';
import { MappingType, RangeMap, ValueMap } from 'app/types';
interface State {
mappings: Array<ValueMap | RangeMap>;
nextIdToAdd: number;
}
export default class ValueMappings extends PureComponent<OptionModuleProps, State> {
constructor(props) {
super(props);
const mappings = props.options.mappings;
this.state = {
mappings: mappings || [],
nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromMappings(mappings) : 1,
};
}
getMaxIdFromMappings(mappings) {
return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
}
addMapping = () =>
this.setState(prevState => ({
mappings: [
...prevState.mappings,
{
id: prevState.nextIdToAdd,
operator: '',
value: '',
text: '',
type: MappingType.ValueToText,
from: '',
to: '',
},
],
nextIdToAdd: prevState.nextIdToAdd + 1,
}));
onRemoveMapping = id => {
this.setState(
prevState => ({
mappings: prevState.mappings.filter(m => {
return m.id !== id;
}),
}),
() => {
this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
}
);
};
updateGauge = mapping => {
this.setState(
prevState => ({
mappings: prevState.mappings.map(m => {
if (m.id === mapping.id) {
return { ...mapping };
}
return m;
}),
}),
() => {
this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
}
);
};
render() {
const { mappings } = this.state;
return (
<div className="section gf-form-group">
<h5 className="page-heading">Value mappings</h5>
<div>
{mappings.length > 0 &&
mappings.map((mapping, index) => (
<MappingRow
key={`${mapping.text}-${index}`}
mapping={mapping}
updateMapping={this.updateGauge}
removeMapping={() => this.onRemoveMapping(mapping.id)}
/>
))}
</div>
<div className="add-mapping-row" onClick={this.addMapping}>
<div className="add-mapping-row-icon">
<i className="fa fa-plus" />
</div>
<div className="add-mapping-row-label">Add mapping</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div
className="section gf-form-group"
>
<h5
className="page-heading"
>
Value mappings
</h5>
<div>
<MappingRow
key="Ok-0"
mapping={
Object {
"id": 1,
"operator": "",
"text": "Ok",
"type": 1,
"value": "20",
}
}
removeMapping={[Function]}
updateMapping={[Function]}
/>
<MappingRow
key="Meh-1"
mapping={
Object {
"from": "21",
"id": 2,
"operator": "",
"text": "Meh",
"to": "30",
"type": 2,
}
}
removeMapping={[Function]}
updateMapping={[Function]}
/>
</div>
<div
className="add-mapping-row"
onClick={[Function]}
>
<div
className="add-mapping-row-icon"
>
<i
className="fa fa-plus"
/>
</div>
<div
className="add-mapping-row-label"
>
Add mapping
</div>
</div>
</div>
`;

View File

@ -1,10 +1,19 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import Gauge from 'app/viz/Gauge'; import Gauge from 'app/viz/Gauge';
import { NullValueMode, PanelOptionsProps, PanelProps, Threshold } from 'app/types';
import { getTimeSeriesVMs } from 'app/viz/state/timeSeries'; import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
import ValueOptions from './ValueOptions'; import ValueOptions from './ValueOptions';
import GaugeOptions from './GaugeOptions'; import GaugeOptions from './GaugeOptions';
import Thresholds from './Thresholds'; import Thresholds from './Thresholds';
import ValueMappings from './ValueMappings';
import {
BasicGaugeColor,
NullValueMode,
PanelOptionsProps,
PanelProps,
RangeMap,
Threshold,
ValueMap,
} from 'app/types';
export interface OptionsProps { export interface OptionsProps {
decimals: number; decimals: number;
@ -15,6 +24,7 @@ export interface OptionsProps {
suffix: string; suffix: string;
unit: string; unit: string;
thresholds: Threshold[]; thresholds: Threshold[];
mappings: Array<RangeMap | ValueMap>;
} }
export interface OptionModuleProps { export interface OptionModuleProps {
@ -30,6 +40,14 @@ export const defaultProps = {
showThresholdMarkers: true, showThresholdMarkers: true,
showThresholdLabels: false, showThresholdLabels: false,
suffix: '', suffix: '',
decimals: 0,
stat: '',
unit: '',
mappings: [],
thresholds: [
{ index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
{ index: 1, label: 'Max', value: 100, canRemove: false },
],
}, },
}; };
@ -52,11 +70,17 @@ class Options extends PureComponent<PanelOptionsProps<OptionsProps>> {
static defaultProps = defaultProps; static defaultProps = defaultProps;
render() { render() {
const { onChange, options } = this.props;
return ( return (
<div> <div>
<ValueOptions onChange={this.props.onChange} options={this.props.options} /> <div className="form-section">
<GaugeOptions onChange={this.props.onChange} options={this.props.options} /> <ValueOptions onChange={onChange} options={options} />
<Thresholds onChange={this.props.onChange} options={this.props.options} /> <GaugeOptions onChange={onChange} options={options} />
<Thresholds onChange={onChange} options={options} />
</div>
<div className="form-section">
<ValueMappings onChange={onChange} options={options} />
</div>
</div> </div>
); );
} }

View File

@ -1,200 +1,190 @@
<div class="edit-tab-with-sidemenu"> <div class="edit-tab-content" ng-repeat="style in editor.panel.styles">
<aside class="edit-sidemenu-aside"> <p class="column-styles-heading">{{style.pattern || 'New rule'}}</p>
<ul class="edit-sidemenu"> <div class="section gf-form-group">
<li ng-repeat="style in editor.panel.styles" ng-class="{active: editor.activeStyleIndex === $index}"> <h5 class="section-heading">Options</h5>
<a ng-click="editor.activeStyleIndex = $index">{{style.pattern || 'New rule'}}</a> <div class="gf-form-inline">
</li> <div class="gf-form">
<li> <label class="gf-form-label width-13">Apply to columns named</label>
<a class="pointer" ng-click="editor.addColumnStyle()"> <input type="text" placeholder="Name or regex" class="gf-form-input width-13" ng-model="style.pattern" bs-tooltip="'Specify regex using /my.*regex/ syntax'"
<i class="fa fa-plus"></i>&nbsp;Add
</a>
</li>
</ul>
</aside>
<div class="edit-tab-content" ng-repeat="style in editor.panel.styles" ng-if="editor.activeStyleIndex === $index">
<div class="section gf-form-group">
<h5 class="section-heading">Options</h5>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label width-13">Apply to columns named</label>
<input type="text" placeholder="Name or regex" class="gf-form-input width-13" ng-model="style.pattern" bs-tooltip="'Specify regex using /my.*regex/ syntax'"
bs-typeahead="editor.getColumnNames" ng-blur="editor.render()" data-min-length=0 data-items=100 ng-model-onblur bs-typeahead="editor.getColumnNames" ng-blur="editor.render()" data-min-length=0 data-items=100 ng-model-onblur
data-placement="right"> data-placement="right">
</div>
</div> </div>
<div class="gf-form" ng-if="style.type !== 'hidden'">
<label class="gf-form-label width-13">Column Header</label>
<input type="text" class="gf-form-input width-13" ng-model="style.alias" ng-change="editor.render()" ng-model-onblur placeholder="Override header label">
</div>
<gf-form-switch class="gf-form" label-class="width-13" label="Render value as link" checked="style.link" change="editor.render()"></gf-form-switch>
</div> </div>
<div class="gf-form" ng-if="style.type !== 'hidden'">
<label class="gf-form-label width-13">Column Header</label>
<input type="text" class="gf-form-input width-13" ng-model="style.alias" ng-change="editor.render()" ng-model-onblur placeholder="Override header label">
</div>
<gf-form-switch class="gf-form" label-class="width-13" label="Render value as link" checked="style.link" change="editor.render()"></gf-form-switch>
</div>
<div class="section gf-form-group"> <div class="section gf-form-group">
<h5 class="section-heading">Type</h5> <h5 class="section-heading">Type</h5>
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-11">Type</label> <label class="gf-form-label width-11">Type</label>
<div class="gf-form-select-wrapper width-16"> <div class="gf-form-select-wrapper width-16">
<select class="gf-form-input" ng-model="style.type" ng-options="c.value as c.text for c in editor.columnTypes" ng-change="editor.render()"></select> <select class="gf-form-input" ng-model="style.type" ng-options="c.value as c.text for c in editor.columnTypes" ng-change="editor.render()"></select>
</div>
</div> </div>
<div class="gf-form" ng-if="style.type === 'date'"> </div>
<label class="gf-form-label width-11">Date Format</label> <div class="gf-form" ng-if="style.type === 'date'">
<gf-form-dropdown model="style.dateFormat" css-class="gf-form-input width-16" lookup-text="true" <label class="gf-form-label width-11">Date Format</label>
<gf-form-dropdown model="style.dateFormat" css-class="gf-form-input width-16" lookup-text="true"
get-options="editor.dateFormats" on-change="editor.render()" allow-custom="true"> get-options="editor.dateFormats" on-change="editor.render()" allow-custom="true">
</gf-form-dropdown> </gf-form-dropdown>
</div> </div>
<div ng-if="style.type === 'string'"> <div ng-if="style.type === 'string'">
<gf-form-switch class="gf-form" label-class="width-11" ng-if="style.type === 'string'" label="Sanitize HTML" checked="style.sanitize" <gf-form-switch class="gf-form" label-class="width-11" ng-if="style.type === 'string'" label="Sanitize HTML" checked="style.sanitize"
change="editor.render()"></gf-form-switch> change="editor.render()"></gf-form-switch>
</div> </div>
<div ng-if="style.type === 'string'"> <div ng-if="style.type === 'string'">
<gf-form-switch class="gf-form" label-class="width-11" ng-if="style.type === 'string'" label="Preserve Formatting" checked="style.preserveFormat" <gf-form-switch class="gf-form" label-class="width-11" ng-if="style.type === 'string'" label="Preserve Formatting" checked="style.preserveFormat"
change="editor.render()"></gf-form-switch> change="editor.render()"></gf-form-switch>
</div> </div>
<div ng-if="style.type === 'number'"> <div ng-if="style.type === 'number'">
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-11">Unit</label> <label class="gf-form-label width-11">Unit</label>
<div class="gf-form-dropdown-typeahead width-16" ng-model="style.unit" dropdown-typeahead2="editor.unitFormats" dropdown-typeahead-on-select="editor.setUnitFormat(style, $subItem)"></div> <div class="gf-form-dropdown-typeahead width-16" ng-model="style.unit" dropdown-typeahead2="editor.unitFormats" dropdown-typeahead-on-select="editor.setUnitFormat(style, $subItem)"></div>
</div> </div>
<div class="gf-form"> <div class="gf-form">
<label class="gf-form-label width-11">Decimals</label> <label class="gf-form-label width-11">Decimals</label>
<input type="number" class="gf-form-input width-4" data-placement="right" ng-model="style.decimals" ng-change="editor.render()" <input type="number" class="gf-form-input width-4" data-placement="right" ng-model="style.decimals" ng-change="editor.render()"
ng-model-onblur> ng-model-onblur>
</div>
</div> </div>
</div> </div>
</div>
<div class="section gf-form-group" ng-if="style.type === 'string'"> <div class="section gf-form-group" ng-if="style.type === 'string'">
<h5 class="section-heading">Value Mappings</h5> <h5 class="section-heading">Value Mappings</h5>
<div class="editor-row"> <div class="editor-row">
<div class="gf-form-group"> <div class="gf-form-group">
<div class="gf-form"> <div class="gf-form">
<span class="gf-form-label"> <span class="gf-form-label">
Type Type
</span> </span>
<div class="gf-form-select-wrapper"> <div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="style.mappingType" <select class="gf-form-input" ng-model="style.mappingType"
ng-options="c.value as c.text for c in editor.mappingTypes" ng-change="editor.render()"></select> ng-options="c.value as c.text for c in editor.mappingTypes" ng-change="editor.render()"></select>
</div>
</div>
<div class="gf-form-group" ng-if="style.mappingType==1">
<div class="gf-form" ng-repeat="map in style.valueMaps">
<span class="gf-form-label">
<i class="fa fa-remove pointer" ng-click="editor.removeValueMap(style, $index)"></i>
</span>
<input type="text" class="gf-form-input max-width-6" ng-model="map.value" placeholder="Value" ng-blur="editor.render()">
<label class="gf-form-label">
<i class="fa fa-arrow-right"></i>
</label>
<input type="text" class="gf-form-input max-width-8" ng-model="map.text" placeholder="Text" ng-blur="editor.render()">
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" ng-click="editor.addValueMap(style)"><i class="fa fa-plus"></i></a>
</label>
</div>
</div>
<div class="gf-form-group" ng-if="style.mappingType==2">
<div class="gf-form" ng-repeat="rangeMap in style.rangeMaps">
<span class="gf-form-label">
<i class="fa fa-remove pointer" ng-click="editor.removeRangeMap(style, $index)"></i>
</span>
<span class="gf-form-label">From</span>
<input type="text" ng-model="rangeMap.from" class="gf-form-input max-width-6" ng-blur="editor.render()">
<span class="gf-form-label">To</span>
<input type="text" ng-model="rangeMap.to" class="gf-form-input max-width-6" ng-blur="editor.render()">
<span class="gf-form-label">Text</span>
<input type="text" ng-model="rangeMap.text" class="gf-form-input max-width-8" ng-blur="editor.render()">
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" ng-click="editor.addRangeMap(style)"><i class="fa fa-plus"></i></a>
</label>
</div>
</div> </div>
</div> </div>
</div> <div class="gf-form-group" ng-if="style.mappingType==1">
</div> <div class="gf-form" ng-repeat="map in style.valueMaps">
<span class="gf-form-label">
<div class="section gf-form-group" ng-if="['number', 'string'].indexOf(style.type) !== -1"> <i class="fa fa-remove pointer" ng-click="editor.removeValueMap(style, $index)"></i>
<h5 class="section-heading">Thresholds</h5>
<div class="gf-form">
<label class="gf-form-label width-8">Thresholds
<tip>Comma separated values</tip>
</label>
<input type="text" class="gf-form-input width-10" ng-model="style.thresholds" placeholder="50,80" ng-blur="editor.render()"
array-join>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Color Mode</label>
<div class="gf-form-select-wrapper width-10">
<select class="gf-form-input" ng-model="style.colorMode" ng-options="c.value as c.text for c in editor.colorModes" ng-change="editor.render()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Colors</label>
<span class="gf-form-label">
<color-picker color="style.colors[0]" onChange="editor.onColorChange($index, 0)"></color-picker>
</span>
<span class="gf-form-label">
<color-picker color="style.colors[1]" onChange="editor.onColorChange($index, 1)"></color-picker>
</span>
<span class="gf-form-label">
<color-picker color="style.colors[2]" onChange="editor.onColorChange($index, 2)"></color-picker>
</span>
<div class="gf-form-label">
<a class="pointer" ng-click="editor.invertColorOrder($index)">Invert</a>
</div>
</div>
</div>
<div class="section gf-form-group" ng-if="style.link">
<h5 class="section-heading">Link</h5>
<div class="gf-form">
<label class="gf-form-label width-9">
Url
<info-popover mode="right-normal">
<p>Specify an URL (relative or absolute)</p>
<span>
Use special variables to specify cell values:
<br>
<em>${__cell}</em> refers to current cell value
<br>
<em>${__cell_n}</em> refers to Nth column value in current row. Column indexes are started from 0. For instance,
<em>${__cell_1}</em> refers to second column's value.
<br>
<em>${__cell:raw}</em> syntax. By default values are URI encoded. If the value is a complete URL you can disable all encoding using
</span> </span>
</info-popover> <input type="text" class="gf-form-input max-width-6" ng-model="map.value" placeholder="Value" ng-blur="editor.render()">
</label> <label class="gf-form-label">
<input type="text" class="gf-form-input width-29" ng-model="style.linkUrl" ng-blur="editor.render()" ng-model-onblur data-placement="right"> <i class="fa fa-arrow-right"></i>
</label>
<input type="text" class="gf-form-input max-width-8" ng-model="map.text" placeholder="Text" ng-blur="editor.render()">
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" ng-click="editor.addValueMap(style)"><i class="fa fa-plus"></i></a>
</label>
</div>
</div>
<div class="gf-form-group" ng-if="style.mappingType==2">
<div class="gf-form" ng-repeat="rangeMap in style.rangeMaps">
<span class="gf-form-label">
<i class="fa fa-remove pointer" ng-click="editor.removeRangeMap(style, $index)"></i>
</span>
<span class="gf-form-label">From</span>
<input type="text" ng-model="rangeMap.from" class="gf-form-input max-width-6" ng-blur="editor.render()">
<span class="gf-form-label">To</span>
<input type="text" ng-model="rangeMap.to" class="gf-form-input max-width-6" ng-blur="editor.render()">
<span class="gf-form-label">Text</span>
<input type="text" ng-model="rangeMap.text" class="gf-form-input max-width-8" ng-blur="editor.render()">
</div>
<div class="gf-form">
<label class="gf-form-label">
<a class="pointer" ng-click="editor.addRangeMap(style)"><i class="fa fa-plus"></i></a>
</label>
</div>
</div>
</div> </div>
<div class="gf-form"> </div>
<label class="gf-form-label width-9"> </div>
Tooltip
<info-popover mode="right-normal"> <div class="section gf-form-group" ng-if="['number', 'string'].indexOf(style.type) !== -1">
<p>Specify text for link tooltip.</p> <h5 class="section-heading">Thresholds</h5>
<span> <div class="gf-form">
<label class="gf-form-label width-8">Thresholds
<tip>Comma separated values</tip>
</label>
<input type="text" class="gf-form-input width-10" ng-model="style.thresholds" placeholder="50,80" ng-blur="editor.render()"
array-join>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Color Mode</label>
<div class="gf-form-select-wrapper width-10">
<select class="gf-form-input" ng-model="style.colorMode" ng-options="c.value as c.text for c in editor.colorModes" ng-change="editor.render()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Colors</label>
<span class="gf-form-label">
<color-picker color="style.colors[0]" onChange="editor.onColorChange($index, 0)"></color-picker>
</span>
<span class="gf-form-label">
<color-picker color="style.colors[1]" onChange="editor.onColorChange($index, 1)"></color-picker>
</span>
<span class="gf-form-label">
<color-picker color="style.colors[2]" onChange="editor.onColorChange($index, 2)"></color-picker>
</span>
<div class="gf-form-label">
<a class="pointer" ng-click="editor.invertColorOrder($index)">Invert</a>
</div>
</div>
</div>
<div class="section gf-form-group" ng-if="style.link">
<h5 class="section-heading">Link</h5>
<div class="gf-form">
<label class="gf-form-label width-9">
Url
<info-popover mode="right-normal">
<p>Specify an URL (relative or absolute)</p>
<span>
Use special variables to specify cell values:
<br>
<em>${__cell}</em> refers to current cell value
<br>
<em>${__cell_n}</em> refers to Nth column value in current row. Column indexes are started from 0. For instance,
<em>${__cell_1}</em> refers to second column's value.
<br>
<em>${__cell:raw}</em> syntax. By default values are URI encoded. If the value is a complete URL you can disable all encoding using
</span>
</info-popover>
</label>
<input type="text" class="gf-form-input width-29" ng-model="style.linkUrl" ng-blur="editor.render()" ng-model-onblur data-placement="right">
</div>
<div class="gf-form">
<label class="gf-form-label width-9">
Tooltip
<info-popover mode="right-normal">
<p>Specify text for link tooltip.</p>
<span>
This title appears when user hovers pointer over the cell with link. Use the same variables as for URL. This title appears when user hovers pointer over the cell with link. Use the same variables as for URL.
</span> </span>
</info-popover></label> </info-popover>
<input type="text" class="gf-form-input width-29" ng-model="style.linkTooltip" ng-blur="editor.render()" ng-model-onblur </label>
<input type="text" class="gf-form-input width-29" ng-model="style.linkTooltip" ng-blur="editor.render()" ng-model-onblur
data-placement="right"> data-placement="right">
</div>
<gf-form-switch class="gf-form" label-class="width-9" label="Open in new tab" checked="style.linkTargetBlank"></gf-form-switch>
</div> </div>
<gf-form-switch class="gf-form" label-class="width-9" label="Open in new tab" checked="style.linkTargetBlank"></gf-form-switch>
</div>
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="gf-form-group">
<button class="btn btn-danger btn-small" ng-click="editor.removeColumnStyle(style)"> <button class="btn btn-danger btn-small" ng-click="editor.removeColumnStyle(style)">
<i class="fa fa-trash"></i> Remove Rule <i class="fa fa-trash"></i> Remove Rule
</button> </button>
<br />
<br />
</div> </div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="editor.addColumnStyle()">
<i class="fa fa-plus"></i>&nbsp;Add column style
</button>
</div>

View File

@ -21,7 +21,7 @@ import {
DataQueryOptions, DataQueryOptions,
IntervalValues, IntervalValues,
} from './series'; } from './series';
import { PanelProps, PanelOptionsProps, Threshold } from './panel'; import { BasicGaugeColor, MappingType, PanelProps, PanelOptionsProps, RangeMap, Threshold, ValueMap } from './panel';
import { PluginDashboard, PluginMeta, Plugin, PanelPlugin, PluginsState } from './plugins'; import { PluginDashboard, PluginMeta, Plugin, PanelPlugin, PluginsState } from './plugins';
import { Organization, OrganizationState } from './organization'; import { Organization, OrganizationState } from './organization';
import { import {
@ -93,7 +93,11 @@ export {
Threshold, Threshold,
ValidationEvents, ValidationEvents,
ValidationRule, ValidationRule,
ValueMap,
RangeMap,
IntervalValues, IntervalValues,
MappingType,
BasicGaugeColor,
}; };
export interface StoreState { export interface StoreState {

View File

@ -36,3 +36,30 @@ export interface Threshold {
color?: string; color?: string;
canRemove: boolean; canRemove: boolean;
} }
export enum MappingType {
ValueToText = 1,
RangeToText = 2,
}
export enum BasicGaugeColor {
Green = 'rgba(50, 172, 45, 0.97)',
Orange = 'rgba(237, 129, 40, 0.89)',
Red = 'rgb(212, 74, 58)',
}
interface BaseMap {
id: number;
operator: string;
text: string;
type: MappingType;
}
export interface ValueMap extends BaseMap {
value: string;
}
export interface RangeMap extends BaseMap {
from: string;
to: string;
}

View File

@ -1,38 +1,42 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import $ from 'jquery'; import $ from 'jquery';
import { Threshold, TimeSeriesVMs } from 'app/types'; import { MappingType, RangeMap, Threshold, TimeSeriesVMs, ValueMap } from 'app/types';
import config from '../core/config'; import config from '../core/config';
import kbn from '../core/utils/kbn'; import kbn from '../core/utils/kbn';
interface Props { interface Props {
decimals: number; decimals: number;
height: number;
mappings: Array<RangeMap | ValueMap>;
maxValue: number;
minValue: number;
prefix: string;
timeSeries: TimeSeriesVMs; timeSeries: TimeSeriesVMs;
showThresholdMarkers: boolean;
thresholds: Threshold[]; thresholds: Threshold[];
showThresholdMarkers: boolean;
showThresholdLabels: boolean; showThresholdLabels: boolean;
stat: string;
suffix: string;
unit: string; unit: string;
width: number; width: number;
height: number;
stat: string;
prefix: string;
suffix: string;
} }
export class Gauge extends PureComponent<Props> { export class Gauge extends PureComponent<Props> {
canvasElement: any; canvasElement: any;
static defaultProps = { static defaultProps = {
minValue: 0,
maxValue: 100, maxValue: 100,
mappings: [],
minValue: 0,
prefix: '', prefix: '',
showThresholdMarkers: true, showThresholdMarkers: true,
showThresholdLabels: false, showThresholdLabels: false,
suffix: '', suffix: '',
unit: 'none',
thresholds: [ thresholds: [
{ label: 'Min', value: 0, color: 'rgba(50, 172, 45, 0.97)' }, { label: 'Min', value: 0, color: 'rgba(50, 172, 45, 0.97)' },
{ label: 'Max', value: 100, color: 'rgba(245, 54, 54, 0.9)' }, { label: 'Max', value: 100, color: 'rgba(245, 54, 54, 0.9)' },
], ],
unit: 'none',
}; };
componentDidMount() { componentDidMount() {
@ -43,16 +47,49 @@ export class Gauge extends PureComponent<Props> {
this.draw(); this.draw();
} }
formatWithMappings(mappings, value) {
const valueMaps = mappings.filter(m => m.type === MappingType.ValueToText);
const rangeMaps = mappings.filter(m => m.type === MappingType.RangeToText);
const valueMap = valueMaps.map(mapping => {
if (mapping.value && value === mapping.value) {
return mapping.text;
}
})[0];
const rangeMap = rangeMaps.map(mapping => {
if (mapping.from && mapping.to && value > mapping.from && value < mapping.to) {
return mapping.text;
}
})[0];
return {
rangeMap,
valueMap,
};
}
formatValue(value) { formatValue(value) {
const { decimals, prefix, suffix, unit } = this.props; const { decimals, mappings, prefix, suffix, unit } = this.props;
const formatFunc = kbn.valueFormats[unit]; const formatFunc = kbn.valueFormats[unit];
const formattedValue = formatFunc(value, decimals);
if (mappings.length > 0) {
const { rangeMap, valueMap } = this.formatWithMappings(mappings, formattedValue);
if (valueMap) {
return valueMap;
} else if (rangeMap) {
return rangeMap;
}
}
if (isNaN(value)) { if (isNaN(value)) {
return '-'; return '-';
} }
return `${prefix} ${formatFunc(value, decimals)} ${suffix}`; return `${prefix} ${formattedValue} ${suffix}`;
} }
draw() { draw() {

View File

@ -96,8 +96,6 @@
@import 'components/empty_list_cta'; @import 'components/empty_list_cta';
@import 'components/popper'; @import 'components/popper';
@import 'components/form_select_box'; @import 'components/form_select_box';
@import 'components/user-picker';
@import 'components/description-picker';
@import 'components/panel_editor'; @import 'components/panel_editor';
@import 'components/toolbar'; @import 'components/toolbar';
@import 'components/delete_button'; @import 'components/delete_button';
@ -106,6 +104,7 @@
@import 'components/unit-picker'; @import 'components/unit-picker';
@import 'components/thresholds'; @import 'components/thresholds';
@import 'components/toggle_button_group'; @import 'components/toggle_button_group';
@import 'components/value-mappings';
// PAGES // PAGES
@import 'pages/login'; @import 'pages/login';

View File

@ -271,7 +271,7 @@ $menu-dropdown-shadow: 5px 5px 20px -5px $black;
$tab-border-color: $dark-4; $tab-border-color: $dark-4;
// Toolbar // Toolbar
$toolbar-bg: $black; $toolbar-bg: $input-black;
// Pagination // Pagination
// ------------------------- // -------------------------
@ -375,13 +375,14 @@ $checkbox-color: $dark-1;
//Panel Edit //Panel Edit
// ------------------------- // -------------------------
$panel-editor-shadow: 0 0 20px black; $panel-editor-shadow: 0 0 20px black;
$panel-editor-border: 1px solid $dark-3;
$panel-editor-side-menu-shadow: drop-shadow(0 0 10px $black); $panel-editor-side-menu-shadow: drop-shadow(0 0 10px $black);
$panel-editor-toolbar-view-bg: $black; $panel-editor-toolbar-view-bg: $input-black;
$panel-editor-viz-item-shadow: 0 0 8px $dark-5; $panel-editor-viz-item-shadow: 0 0 8px $dark-5;
$panel-editor-viz-item-border: 1px solid $dark-5; $panel-editor-viz-item-border: 1px solid $dark-5;
$panel-editor-viz-item-shadow-hover: 0 0 4px $blue; $panel-editor-viz-item-shadow-hover: 0 0 4px $blue;
$panel-editor-viz-item-border-hover: 1px solid $blue; $panel-editor-viz-item-border-hover: 1px solid $blue;
$panel-editor-viz-item-bg: $black; $panel-editor-viz-item-bg: $input-black;
$panel-editor-tabs-line-color: #e3e3e3; $panel-editor-tabs-line-color: #e3e3e3;
$panel-editor-viz-item-bg-hover: darken($blue, 47%); $panel-editor-viz-item-bg-hover: darken($blue, 47%);
$panel-editor-viz-item-bg-hover-active: darken($orange, 45%); $panel-editor-viz-item-bg-hover-active: darken($orange, 45%);

View File

@ -384,6 +384,7 @@ $checkbox-color: $gray-7;
//Panel Edit //Panel Edit
// ------------------------- // -------------------------
$panel-editor-shadow: 2px 2px 8px $gray-3; $panel-editor-shadow: 2px 2px 8px $gray-3;
$panel-editor-border: 1px solid $dark-4;
$panel-editor-side-menu-shadow: drop-shadow(0 0 2px $gray-3); $panel-editor-side-menu-shadow: drop-shadow(0 0 2px $gray-3);
$panel-editor-toolbar-view-bg: $white; $panel-editor-toolbar-view-bg: $white;
$panel-editor-viz-item-shadow: 0 0 4px $gray-3; $panel-editor-viz-item-shadow: 0 0 4px $gray-3;

View File

@ -78,6 +78,7 @@
.btn-link { .btn-link {
color: $btn-link-color; color: $btn-link-color;
background: transparent;
} }
// Set the backgrounds // Set the backgrounds

View File

@ -1,11 +0,0 @@
.description-picker-option__button {
position: relative;
text-align: left;
width: 100%;
display: block;
border-radius: 0;
white-space: normal;
i.fa-check {
padding-left: 2px;
}
}

View File

@ -47,12 +47,17 @@ $select-input-bg-disabled: $input-bg-disabled;
.gf-form-select-box__input { .gf-form-select-box__input {
padding-left: 5px; padding-left: 5px;
input {
line-height: inherit;
}
} }
.gf-form-select-box__menu { .gf-form-select-box__menu {
background: $dropdownBackground; background: $input-bg;
box-shadow: $menu-dropdown-shadow;
position: absolute; position: absolute;
z-index: 2; z-index: 2;
min-width: 100%;
} }
.gf-form-select-box__menu-list { .gf-form-select-box__menu-list {
@ -64,16 +69,20 @@ $select-input-bg-disabled: $input-bg-disabled;
width: 100%; width: 100%;
} }
/* .gf-form-select-box__single-value { */
/* } */
.gf-form-select-box__multi-value { .gf-form-select-box__multi-value {
display: inline; display: inline;
} }
.gf-form-select-box__option { .gf-form-select-box__option {
border-left: 2px solid transparent; border-left: 2px solid transparent;
white-space: nowrap;
&.gf-form-select-box__option--is-focused { &.gf-form-select-box__option--is-focused {
color: $dropdownLinkColorHover; color: $dropdownLinkColorHover;
background-color: $dropdownLinkBackgroundHover; background: $menu-dropdown-hover-bg;
@include left-brand-border-gradient(); @include left-brand-border-gradient();
} }
@ -90,7 +99,7 @@ $select-input-bg-disabled: $input-bg-disabled;
.gf-form-select-box__value-container { .gf-form-select-box__value-container {
display: table-cell; display: table-cell;
padding: 8px 10px; padding: 6px 10px;
> div { > div {
display: inline-block; display: inline-block;
} }
@ -119,10 +128,12 @@ $select-input-bg-disabled: $input-bg-disabled;
border-width: 0 5px 5px; border-width: 0 5px 5px;
} }
} }
.gf-form-input--form-dropdown { .gf-form-input--form-dropdown {
padding: 0; padding: 0;
border: 0; border: 0;
overflow: visible; overflow: visible;
position: relative;
} }
.gf-form--has-input-icon { .gf-form--has-input-icon {
@ -130,3 +141,32 @@ $select-input-bg-disabled: $input-bg-disabled;
padding-left: 30px; padding-left: 30px;
} }
} }
.gf-form-select-box__desc-option {
display: flex;
align-items: center;
justify-content: flex-start;
justify-items: center;
cursor: pointer;
padding: 7px 10px;
width: 100%;
}
.gf-form-select-box__desc-option__body {
display: flex;
flex-direction: column;
flex-grow: 1;
padding-right: 10px;
font-weight: 500;
}
.gf-form-select-box__desc-option__desc {
font-weight: normal;
font-size: $font-size-sm;
color: $text-muted;
}
.gf-form-select-box__desc-option__img {
width: 16px;
margin-right: 10px;
}

View File

@ -110,7 +110,6 @@ $input-border: 1px solid $input-border-color;
&--grow { &--grow {
flex-grow: 1; flex-grow: 1;
min-height: 2.6rem;
} }
&--error { &--error {

View File

@ -21,10 +21,10 @@
display: none; display: none;
} }
&.json-formatter-object::after { &.json-formatter-object::after {
content: "No properties"; content: 'No properties';
} }
&.json-formatter-array::after { &.json-formatter-array::after {
content: "[]"; content: '[]';
} }
} }
} }
@ -33,7 +33,9 @@
color: $json-explorer-string-color; color: $json-explorer-string-color;
white-space: normal; white-space: normal;
word-wrap: break-word; word-wrap: break-word;
word-break: break-all;
} }
.json-formatter-number { .json-formatter-number {
color: $json-explorer-number-color; color: $json-explorer-number-color;
} }
@ -87,7 +89,7 @@
&::after { &::after {
display: inline-block; display: inline-block;
transition: transform $json-explorer-rotate-time ease-in; transition: transform $json-explorer-rotate-time ease-in;
content: ""; content: '';
} }
} }

View File

@ -34,7 +34,6 @@
flex-grow: 1; flex-grow: 1;
background: $page-bg; background: $page-bg;
margin: 0 20px 0 84px; margin: 0 20px 0 84px;
border-left: 2px solid $orange;
border-radius: 3px; border-radius: 3px;
box-shadow: $panel-editor-shadow; box-shadow: $panel-editor-shadow;
} }
@ -133,14 +132,19 @@
} }
.viz-picker { .viz-picker {
margin-top: -40px;
padding: 20px;
position: relative;
}
.viz-picker-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 13px;
} }
.viz-picker__item { .viz-picker__item {
background: $panel-editor-viz-item-bg; background: $panel-bg;
border: $panel-editor-viz-item-border; border: $panel-border;
border-radius: 3px; border-radius: 3px;
height: 100px; height: 100px;
width: 150px; width: 150px;
@ -162,7 +166,7 @@
border: 1px solid $orange; border: 1px solid $orange;
} }
&--selected { &:hover {
box-shadow: $panel-editor-viz-item-shadow-hover; box-shadow: $panel-editor-viz-item-shadow-hover;
background: $panel-editor-viz-item-bg-hover; background: $panel-editor-viz-item-bg-hover;
border: $panel-editor-viz-item-border-hover; border: $panel-editor-viz-item-border-hover;
@ -273,6 +277,20 @@
} }
} }
.ds-picker {
position: relative;
min-width: 200px;
}
.ds-picker-menu {
min-width: 400px;
max-width: 500px;
position: absolute;
background: $panel-editor-toolbar-view-bg;
padding: 5px;
overflow: auto;
}
.ds-picker-list__name { .ds-picker-list__name {
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
@ -306,6 +324,13 @@
margin-bottom: 20px; margin-bottom: 20px;
background: $input-label-bg; background: $input-label-bg;
border-radius: 3px; border-radius: 3px;
position: relative;
.btn {
position: absolute;
right: 0;
top: 2px;
}
} }
.form-section__body { .form-section__body {

View File

@ -77,7 +77,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 36px; height: 37px;
width: 37px;
cursor: pointer; cursor: pointer;
} }

View File

@ -1,12 +0,0 @@
.user-picker-option__button {
position: relative;
text-align: left;
width: 100%;
display: block;
border-radius: 0;
}
.user-picker-option__avatar {
width: 20px;
display: inline-block;
margin-right: 10px;
}

View File

@ -0,0 +1,37 @@
.mapping-row {
display: flex;
margin-bottom: 10px;
}
.mapping-row-type {
margin-right: 5px;
}
.mapping-row-input {
margin-right: 5px;
}
.add-mapping-row {
display: flex;
overflow: hidden;
height: 37px;
cursor: pointer;
border-radius: $border-radius;
width: 200px;
}
.add-mapping-row-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
background-color: $green;
}
.add-mapping-row-label {
align-items: center;
display: flex;
padding: 5px 8px;
background-color: $input-label-bg;
width: calc(100% - 36px);
}

View File

@ -28,6 +28,10 @@
} }
} }
.column-styles-heading {
border-bottom: 1px solid $gray-1;
}
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
.edit-tab-with-sidemenu { .edit-tab-with-sidemenu {
flex-direction: column; flex-direction: column;

View File

@ -83,6 +83,10 @@ button.close {
position: absolute; position: absolute;
} }
.flex-grow {
flex-grow: 1;
}
.center-vh { .center-vh {
display: flex; display: flex;
align-items: center; align-items: center;