mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge pull request #14274 from grafana/develop
Develop (New Panel Edit UX & Explore All Datasources suppport) -> Master
This commit is contained in:
commit
3efaf52049
1
.gitignore
vendored
1
.gitignore
vendored
@ -76,3 +76,4 @@ debug.test
|
||||
/devenv/bulk_alerting_dashboards/*.json
|
||||
|
||||
/scripts/build/release_publisher/release_publisher
|
||||
*.patch
|
||||
|
@ -25,7 +25,7 @@ the latest master builds [here](https://grafana.com/grafana/download)
|
||||
### Dependencies
|
||||
|
||||
- Go (Latest Stable)
|
||||
- NodeJS LTS
|
||||
- Node.js LTS
|
||||
|
||||
### Building the backend
|
||||
```bash
|
||||
@ -37,7 +37,7 @@ go run build.go build
|
||||
|
||||
### Building frontend assets
|
||||
|
||||
For this you need nodejs (v.6+).
|
||||
For this you need Node.js (LTS version).
|
||||
|
||||
To build the assets, rebuild on file change, and serve them by Grafana's webserver (http://localhost:3000):
|
||||
```bash
|
||||
|
16
package.json
16
package.json
@ -20,9 +20,9 @@
|
||||
"@types/enzyme": "^3.1.13",
|
||||
"@types/jest": "^23.3.2",
|
||||
"@types/node": "^8.0.31",
|
||||
"@types/react": "^16.4.14",
|
||||
"@types/react": "^16.7.6",
|
||||
"@types/react-custom-scrollbars": "^4.0.5",
|
||||
"@types/react-dom": "^16.0.7",
|
||||
"@types/react-dom": "^16.0.9",
|
||||
"@types/react-select": "^2.0.4",
|
||||
"angular-mocks": "1.6.6",
|
||||
"autoprefixer": "^6.4.0",
|
||||
@ -148,17 +148,18 @@
|
||||
"prismjs": "^1.6.0",
|
||||
"prop-types": "^15.6.2",
|
||||
"rc-cascader": "^0.14.0",
|
||||
"react": "^16.5.0",
|
||||
"react": "^16.6.3",
|
||||
"react-custom-scrollbars": "^4.2.1",
|
||||
"react-dom": "^16.5.0",
|
||||
"react-dom": "^16.6.3",
|
||||
"react-grid-layout": "0.16.6",
|
||||
"react-popper": "^1.3.0",
|
||||
"react-highlight-words": "0.11.0",
|
||||
"react-popper": "^0.7.5",
|
||||
"react-redux": "^5.0.7",
|
||||
"react-select": "2.1.0",
|
||||
"@torkelo/react-select": "2.1.1",
|
||||
"react-sizeme": "^2.3.6",
|
||||
"react-table": "^6.8.6",
|
||||
"react-transition-group": "^2.2.1",
|
||||
"react-virtualized": "^9.21.0",
|
||||
"redux": "^4.0.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.3.0",
|
||||
@ -175,6 +176,7 @@
|
||||
"tslint-react": "^3.6.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"caniuse-db": "1.0.30000772"
|
||||
"caniuse-db": "1.0.30000772",
|
||||
"**/@types/react": "16.7.6"
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,17 @@
|
||||
#! /usr/bin/env bash
|
||||
version=5.4.1
|
||||
version=5.4.2
|
||||
|
||||
wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb
|
||||
# wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb
|
||||
#
|
||||
# package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb
|
||||
# package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb
|
||||
# package_cloud push grafana/stable/debian/stretch grafana_${version}_amd64.deb
|
||||
#
|
||||
# package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb
|
||||
# package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose
|
||||
# package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose
|
||||
|
||||
package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb
|
||||
package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb
|
||||
package_cloud push grafana/stable/debian/stretch grafana_${version}_amd64.deb
|
||||
|
||||
package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb
|
||||
package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose
|
||||
package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose
|
||||
|
||||
wget https://dl.grafana.com/release/grafana-${version}-1.x86_64.rpm
|
||||
wget https://dl.grafana.com/oss/release/grafana-${version}-1.x86_64.rpm
|
||||
|
||||
package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm --verbose
|
||||
package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm --verbose
|
||||
|
@ -24,6 +24,7 @@ type DataSourcePlugin struct {
|
||||
Metrics bool `json:"metrics"`
|
||||
Alerting bool `json:"alerting"`
|
||||
Explore bool `json:"explore"`
|
||||
Table bool `json:"tables"`
|
||||
Logs bool `json:"logs"`
|
||||
QueryOptions map[string]bool `json:"queryOptions,omitempty"`
|
||||
BuiltIn bool `json:"builtIn,omitempty"`
|
||||
|
38
public/app/core/components/Animations/FadeIn.tsx
Normal file
38
public/app/core/components/Animations/FadeIn.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React, { SFC } from 'react';
|
||||
import Transition from 'react-transition-group/Transition';
|
||||
|
||||
interface Props {
|
||||
duration: number;
|
||||
children: JSX.Element;
|
||||
in: boolean;
|
||||
unmountOnExit?: boolean;
|
||||
}
|
||||
|
||||
export const FadeIn: SFC<Props> = props => {
|
||||
const defaultStyle = {
|
||||
transition: `opacity ${props.duration}ms linear`,
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
const transitionStyles = {
|
||||
exited: { opacity: 0, display: 'none' },
|
||||
entering: { opacity: 0 },
|
||||
entered: { opacity: 1 },
|
||||
exiting: { opacity: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition in={props.in} timeout={props.duration} unmountOnExit={props.unmountOnExit || false}>
|
||||
{state => (
|
||||
<div
|
||||
style={{
|
||||
...defaultStyle,
|
||||
...transitionStyles[state],
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
};
|
@ -23,7 +23,7 @@ export default ({ children, in: inProp, maxHeight = defaultMaxHeight, style = de
|
||||
const transitionStyles = {
|
||||
exited: { maxHeight: 0 },
|
||||
entering: { maxHeight: maxHeight },
|
||||
entered: { maxHeight: maxHeight, overflow: 'visible' },
|
||||
entered: { maxHeight: 'unset', overflow: 'visible' },
|
||||
exiting: { maxHeight: 0 },
|
||||
};
|
||||
|
||||
|
@ -20,6 +20,7 @@ export default class AppNotificationItem extends Component<Props> {
|
||||
|
||||
render() {
|
||||
const { appNotification, onClearNotification } = this.props;
|
||||
|
||||
return (
|
||||
<div className={`alert-${appNotification.severity} alert`}>
|
||||
<div className="alert-icon">
|
||||
|
@ -0,0 +1,36 @@
|
||||
import { PureComponent } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
export interface Props {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasEventListener: boolean;
|
||||
}
|
||||
|
||||
export class ClickOutsideWrapper extends PureComponent<Props, State> {
|
||||
state = {
|
||||
hasEventListener: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('click', this.onOutsideClick, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('click', this.onOutsideClick, false);
|
||||
}
|
||||
|
||||
onOutsideClick = event => {
|
||||
const domNode = ReactDOM.findDOMNode(this) as Element;
|
||||
|
||||
if (!domNode || !domNode.contains(event.target)) {
|
||||
this.props.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
import React, { PureComponent, ReactNode } from 'react';
|
||||
import ClipboardJS from 'clipboard';
|
||||
|
||||
interface Props {
|
||||
text: () => string;
|
||||
elType?: string;
|
||||
onSuccess?: (evt: any) => void;
|
||||
onError?: (evt: any) => void;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export class CopyToClipboard extends PureComponent<Props> {
|
||||
clipboardjs: any;
|
||||
myRef: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.myRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { text, onSuccess, onError } = this.props;
|
||||
|
||||
this.clipboardjs = new ClipboardJS(this.myRef.current, {
|
||||
text: text,
|
||||
});
|
||||
|
||||
if (onSuccess) {
|
||||
this.clipboardjs.on('success', evt => {
|
||||
evt.clearSelection();
|
||||
onSuccess(evt);
|
||||
});
|
||||
}
|
||||
|
||||
if (onError) {
|
||||
this.clipboardjs.on('error', evt => {
|
||||
console.error('Action:', evt.action);
|
||||
console.error('Trigger:', evt.trigger);
|
||||
onError(evt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.clipboardjs) {
|
||||
this.clipboardjs.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
getElementType = () => {
|
||||
return this.props.elType || 'button';
|
||||
};
|
||||
|
||||
render() {
|
||||
const { elType, text, children, onError, onSuccess, ...restProps } = this.props;
|
||||
|
||||
return React.createElement(
|
||||
this.getElementType(),
|
||||
{
|
||||
ref: this.myRef,
|
||||
...restProps,
|
||||
},
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
}
|
@ -28,8 +28,8 @@ class CustomScrollbar extends PureComponent<Props> {
|
||||
<Scrollbars
|
||||
className={customClassName}
|
||||
autoHeight={true}
|
||||
autoHeightMin={'100%'}
|
||||
autoHeightMax={'100%'}
|
||||
autoHeightMin={'inherit'}
|
||||
autoHeightMax={'inherit'}
|
||||
renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
|
||||
renderTrackVertical={props => <div {...props} className="track-vertical" />}
|
||||
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
|
||||
|
@ -6,8 +6,8 @@ exports[`CustomScrollbar renders correctly 1`] = `
|
||||
style={
|
||||
Object {
|
||||
"height": "auto",
|
||||
"maxHeight": "100%",
|
||||
"minHeight": "100%",
|
||||
"maxHeight": "inherit",
|
||||
"minHeight": "inherit",
|
||||
"overflow": "hidden",
|
||||
"position": "relative",
|
||||
"width": "100%",
|
||||
@ -23,8 +23,8 @@ exports[`CustomScrollbar renders correctly 1`] = `
|
||||
"left": undefined,
|
||||
"marginBottom": 0,
|
||||
"marginRight": 0,
|
||||
"maxHeight": "calc(100% + 0px)",
|
||||
"minHeight": "calc(100% + 0px)",
|
||||
"maxHeight": "calc(inherit + 0px)",
|
||||
"minHeight": "calc(inherit + 0px)",
|
||||
"overflow": "scroll",
|
||||
"position": "relative",
|
||||
"right": undefined,
|
||||
|
43
public/app/core/components/Form/Element.tsx
Normal file
43
public/app/core/components/Form/Element.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import React, { PureComponent, ReactNode, ReactElement } from 'react';
|
||||
import { Label } from './Label';
|
||||
import { uniqueId } from 'lodash';
|
||||
|
||||
interface Props {
|
||||
label?: ReactNode;
|
||||
labelClassName?: string;
|
||||
id?: string;
|
||||
children: ReactElement<any>;
|
||||
}
|
||||
|
||||
export class Element extends PureComponent<Props> {
|
||||
elementId: string = this.props.id || uniqueId('form-element-');
|
||||
|
||||
get elementLabel() {
|
||||
const { label, labelClassName } = this.props;
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<Label htmlFor={this.elementId} className={labelClassName}>
|
||||
{label}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
get children() {
|
||||
const { children } = this.props;
|
||||
|
||||
return React.cloneElement(children, { id: this.elementId });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="our-custom-wrapper-class">
|
||||
{this.elementLabel}
|
||||
{this.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
53
public/app/core/components/Form/Input.test.tsx
Normal file
53
public/app/core/components/Form/Input.test.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Input, EventsWithValidation } from './Input';
|
||||
import { ValidationEvents } from 'app/types';
|
||||
|
||||
const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars';
|
||||
const testBlurValidation: ValidationEvents = {
|
||||
[EventsWithValidation.onBlur]: [
|
||||
{
|
||||
rule: (value: string) => {
|
||||
if (!value || value.length < 3) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
errorMessage: TEST_ERROR_MESSAGE,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('Input', () => {
|
||||
it('renders correctly', () => {
|
||||
const tree = renderer.create(<Input />).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should validate with error onBlur', () => {
|
||||
const wrapper = shallow(<Input validationEvents={testBlurValidation} />);
|
||||
const evt = {
|
||||
persist: jest.fn,
|
||||
target: {
|
||||
value: 'I can not be more than 2 chars',
|
||||
},
|
||||
};
|
||||
|
||||
wrapper.find('input').simulate('blur', evt);
|
||||
expect(wrapper.state('error')).toBe(TEST_ERROR_MESSAGE);
|
||||
});
|
||||
|
||||
it('should validate without error onBlur', () => {
|
||||
const wrapper = shallow(<Input validationEvents={testBlurValidation} />);
|
||||
const evt = {
|
||||
persist: jest.fn,
|
||||
target: {
|
||||
value: 'Hi',
|
||||
},
|
||||
};
|
||||
|
||||
wrapper.find('input').simulate('blur', evt);
|
||||
expect(wrapper.state('error')).toBe(null);
|
||||
});
|
||||
});
|
94
public/app/core/components/Form/Input.tsx
Normal file
94
public/app/core/components/Form/Input.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { ValidationEvents, ValidationRule } from 'app/types';
|
||||
import { validate, hasValidationEvent } from 'app/core/utils/validate';
|
||||
|
||||
export enum InputStatus {
|
||||
Invalid = 'invalid',
|
||||
Valid = 'valid',
|
||||
}
|
||||
|
||||
export enum InputTypes {
|
||||
Text = 'text',
|
||||
Number = 'number',
|
||||
Password = 'password',
|
||||
Email = 'email',
|
||||
}
|
||||
|
||||
export enum EventsWithValidation {
|
||||
onBlur = 'onBlur',
|
||||
onFocus = 'onFocus',
|
||||
onChange = 'onChange',
|
||||
}
|
||||
|
||||
interface Props extends React.HTMLProps<HTMLInputElement> {
|
||||
validationEvents?: ValidationEvents;
|
||||
hideErrorMessage?: boolean;
|
||||
|
||||
// Override event props and append status as argument
|
||||
onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
|
||||
onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
|
||||
onChange?: (event: React.FormEvent<HTMLInputElement>, status?: InputStatus) => void;
|
||||
}
|
||||
|
||||
export class Input extends PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
};
|
||||
|
||||
state = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
get status() {
|
||||
return this.state.error ? InputStatus.Invalid : InputStatus.Valid;
|
||||
}
|
||||
|
||||
get isInvalid() {
|
||||
return this.status === InputStatus.Invalid;
|
||||
}
|
||||
|
||||
validatorAsync = (validationRules: ValidationRule[]) => {
|
||||
return evt => {
|
||||
const errors = validate(evt.target.value, validationRules);
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
error: errors ? errors[0] : null,
|
||||
};
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => {
|
||||
const inputElementProps = { ...restProps };
|
||||
Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => {
|
||||
if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) {
|
||||
inputElementProps[eventName] = async evt => {
|
||||
evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling
|
||||
if (hasValidationEvent(eventName, validationEvents)) {
|
||||
await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]);
|
||||
}
|
||||
if (restProps[eventName]) {
|
||||
restProps[eventName].apply(null, [evt, this.status]);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
return inputElementProps;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { validationEvents, className, hideErrorMessage, ...restProps } = this.props;
|
||||
const { error } = this.state;
|
||||
const inputClassName = classNames('gf-form-input', { invalid: this.isInvalid }, className);
|
||||
const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents);
|
||||
|
||||
return (
|
||||
<div className="our-custom-wrapper-class">
|
||||
<input {...inputElementProps} className={inputClassName} />
|
||||
{error && !hideErrorMessage && <span>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
19
public/app/core/components/Form/Label.tsx
Normal file
19
public/app/core/components/Form/Label.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React, { PureComponent, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
htmlFor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export class Label extends PureComponent<Props> {
|
||||
render() {
|
||||
const { children, htmlFor, className } = this.props;
|
||||
|
||||
return (
|
||||
<label className={`custom-label-class ${className || ''}`} htmlFor={htmlFor}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Input renders correctly 1`] = `
|
||||
<div
|
||||
className="our-custom-wrapper-class"
|
||||
>
|
||||
<input
|
||||
className="gf-form-input"
|
||||
/>
|
||||
</div>
|
||||
`;
|
3
public/app/core/components/Form/index.ts
Normal file
3
public/app/core/components/Form/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { Element } from './Element';
|
||||
export { Input } from './Input';
|
||||
export { Label } from './Label';
|
51
public/app/core/components/JSONFormatter/JSONFormatter.tsx
Normal file
51
public/app/core/components/JSONFormatter/JSONFormatter.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React, { PureComponent, createRef } from 'react';
|
||||
// import JSONFormatterJS, { JSONFormatterConfiguration } from 'json-formatter-js';
|
||||
import { JsonExplorer } from 'app/core/core'; // We have made some monkey-patching of json-formatter-js so we can't switch right now
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
json: {};
|
||||
config?: any;
|
||||
open?: number;
|
||||
onDidRender?: (formattedJson: any) => void;
|
||||
}
|
||||
|
||||
export class JSONFormatter extends PureComponent<Props> {
|
||||
private wrapperRef = createRef<HTMLDivElement>();
|
||||
|
||||
static defaultProps = {
|
||||
open: 3,
|
||||
config: {
|
||||
animateOpen: true,
|
||||
},
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.renderJson();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.renderJson();
|
||||
}
|
||||
|
||||
renderJson = () => {
|
||||
const { json, config, open, onDidRender } = this.props;
|
||||
const wrapperEl = this.wrapperRef.current;
|
||||
const formatter = new JsonExplorer(json, open, config);
|
||||
const hasChildren: boolean = wrapperEl.hasChildNodes();
|
||||
if (hasChildren) {
|
||||
wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild);
|
||||
} else {
|
||||
wrapperEl.appendChild(formatter.render());
|
||||
}
|
||||
|
||||
if (onDidRender) {
|
||||
onDidRender(formatter.json);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className } = this.props;
|
||||
return <div className={className} ref={this.wrapperRef} />;
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ interface Props {
|
||||
for?: string;
|
||||
children: ReactNode;
|
||||
width?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Label: SFC<Props> = props => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { Component } from 'react';
|
||||
import { UserPicker } from 'app/core/components/Picker/UserPicker';
|
||||
import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker';
|
||||
import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { UserPicker } from 'app/core/components/Select/UserPicker';
|
||||
import { TeamPicker, Team } from 'app/core/components/Select/TeamPicker';
|
||||
import { Select, SelectOptionItem } from 'app/core/components/Select/Select';
|
||||
import { User } from 'app/types';
|
||||
import {
|
||||
dashboardPermissionLevels,
|
||||
@ -61,7 +61,7 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 });
|
||||
};
|
||||
|
||||
onPermissionChanged = (permission: OptionWithDescription) => {
|
||||
onPermissionChanged = (permission: SelectOptionItem) => {
|
||||
this.setState({ permission: permission.value });
|
||||
};
|
||||
|
||||
@ -121,11 +121,11 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
|
||||
) : null}
|
||||
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={dashboardPermissionLevels}
|
||||
onSelected={this.onPermissionChanged}
|
||||
disabled={false}
|
||||
className={'gf-form-select-box__control--menu-right'}
|
||||
<Select
|
||||
isSearchable={false}
|
||||
options={dashboardPermissionLevels}
|
||||
onChange={this.onPermissionChanged}
|
||||
className="gf-form-select-box__control--menu-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { Component } from 'react';
|
||||
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
|
||||
import Select from 'app/core/components/Select/Select';
|
||||
import { dashboardPermissionLevels } from 'app/types/acl';
|
||||
|
||||
export interface Props {
|
||||
@ -9,6 +9,7 @@ export interface Props {
|
||||
export default class DisabledPermissionListItem extends Component<Props, any> {
|
||||
render() {
|
||||
const { item } = this.props;
|
||||
const currentPermissionLevel = dashboardPermissionLevels.find(dp => dp.value === item.permission);
|
||||
|
||||
return (
|
||||
<tr className="gf-form-disabled">
|
||||
@ -23,12 +24,12 @@ export default class DisabledPermissionListItem extends Component<Props, any> {
|
||||
<td className="query-keyword">Can</td>
|
||||
<td>
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={dashboardPermissionLevels}
|
||||
onSelected={() => {}}
|
||||
disabled={true}
|
||||
className={'gf-form-select-box__control--menu-right'}
|
||||
value={item.permission}
|
||||
<Select
|
||||
options={dashboardPermissionLevels}
|
||||
onChange={() => {}}
|
||||
isDisabled={true}
|
||||
className="gf-form-select-box__control--menu-right"
|
||||
value={currentPermissionLevel}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { Select } from 'app/core/components/Select/Select';
|
||||
import { dashboardPermissionLevels, DashboardAcl, PermissionLevel } from 'app/types/acl';
|
||||
import { FolderInfo } from 'app/types';
|
||||
|
||||
@ -50,6 +50,7 @@ export default class PermissionsListItem extends PureComponent<Props> {
|
||||
render() {
|
||||
const { item, folderInfo } = this.props;
|
||||
const inheritedFromRoot = item.dashboardId === -1 && !item.inherited;
|
||||
const currentPermissionLevel = dashboardPermissionLevels.find(dp => dp.value === item.permission);
|
||||
|
||||
return (
|
||||
<tr className={setClassNameHelper(item.inherited)}>
|
||||
@ -74,12 +75,13 @@ export default class PermissionsListItem extends PureComponent<Props> {
|
||||
<td className="query-keyword">Can</td>
|
||||
<td>
|
||||
<div className="gf-form">
|
||||
<DescriptionPicker
|
||||
optionsWithDesc={dashboardPermissionLevels}
|
||||
onSelected={this.onPermissionChanged}
|
||||
disabled={item.inherited}
|
||||
className={'gf-form-select-box__control--menu-right'}
|
||||
value={item.permission}
|
||||
<Select
|
||||
isSearchable={false}
|
||||
options={dashboardPermissionLevels}
|
||||
onChange={this.onPermissionChanged}
|
||||
isDisabled={item.inherited}
|
||||
className="gf-form-select-box__control--menu-right"
|
||||
value={currentPermissionLevel}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
@ -1,25 +0,0 @@
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> {
|
||||
data: any;
|
||||
}
|
||||
|
||||
export const Option = (props: ExtendedOptionProps) => {
|
||||
const { children, isSelected, data, className } = props;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className={`description-picker-option__button btn btn-link ${className}`}>
|
||||
{isSelected && <i className="fa fa-check pull-right" aria-hidden="true" />}
|
||||
<div className="gf-form">{children}</div>
|
||||
<div className="gf-form">
|
||||
<div className="muted width-17">{data.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
export default Option;
|
@ -1,52 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import Select from 'react-select';
|
||||
import DescriptionOption from './DescriptionOption';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import ResetStyles from './ResetStyles';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
|
||||
export interface OptionWithDescription {
|
||||
value: any;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
optionsWithDesc: OptionWithDescription[];
|
||||
onSelected: (permission) => void;
|
||||
disabled: boolean;
|
||||
className?: string;
|
||||
value?: any;
|
||||
}
|
||||
|
||||
const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
|
||||
|
||||
class DescriptionPicker extends Component<Props, any> {
|
||||
render() {
|
||||
const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
|
||||
const selectedOption = getSelectedOption(optionsWithDesc, value);
|
||||
return (
|
||||
<div className="permissions-picker">
|
||||
<Select
|
||||
placeholder="Choose"
|
||||
classNamePrefix={`gf-form-select-box`}
|
||||
className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
options={optionsWithDesc}
|
||||
components={{
|
||||
Option: DescriptionOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
}}
|
||||
styles={ResetStyles}
|
||||
isDisabled={disabled}
|
||||
onChange={onSelected}
|
||||
getOptionValue={i => i.value}
|
||||
getOptionLabel={i => i.label}
|
||||
value={selectedOption}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DescriptionPicker;
|
@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
|
||||
export interface Props {
|
||||
children: Element;
|
||||
}
|
||||
|
||||
export const PickerOption = (props: OptionProps<any>) => {
|
||||
const { children, className } = props;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className={`description-picker-option__button btn btn-link ${className}`}>{children}</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
export default PickerOption;
|
@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> {
|
||||
data: any;
|
||||
}
|
||||
|
||||
export const PickerOption = (props: ExtendedOptionProps) => {
|
||||
const { children, data, className } = props;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className={`description-picker-option__button btn btn-link ${className}`}>
|
||||
{data.avatarUrl && <img src={data.avatarUrl} alt={data.label} className="user-picker-option__avatar" />}
|
||||
{children}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
export default PickerOption;
|
@ -1,49 +0,0 @@
|
||||
import React, { SFC } from 'react';
|
||||
import Select from 'react-select';
|
||||
import DescriptionOption from './DescriptionOption';
|
||||
import ResetStyles from './ResetStyles';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
defaultValue?: any;
|
||||
getOptionLabel: (item: any) => string;
|
||||
getOptionValue: (item: any) => string;
|
||||
onSelected: (item: any) => {} | void;
|
||||
options: any[];
|
||||
placeholder?: string;
|
||||
width: number;
|
||||
value: any;
|
||||
}
|
||||
|
||||
const SimplePicker: SFC<Props> = ({
|
||||
className,
|
||||
defaultValue,
|
||||
getOptionLabel,
|
||||
getOptionValue,
|
||||
onSelected,
|
||||
options,
|
||||
placeholder,
|
||||
width,
|
||||
value,
|
||||
}) => {
|
||||
return (
|
||||
<Select
|
||||
classNamePrefix={`gf-form-select-box`}
|
||||
className={`width-${width} gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
components={{
|
||||
Option: DescriptionOption,
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
getOptionLabel={getOptionLabel}
|
||||
getOptionValue={getOptionValue}
|
||||
isSearchable={false}
|
||||
onChange={onSelected}
|
||||
options={options}
|
||||
placeholder={placeholder || 'Choose'}
|
||||
styles={ResetStyles}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimplePicker;
|
@ -1,17 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PickerOption renders correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
className="description-picker-option__button btn btn-link class-for-user-picker"
|
||||
>
|
||||
<img
|
||||
alt="User picker label"
|
||||
className="user-picker-option__avatar"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
Model title
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
35
public/app/core/components/Portal/Portal.tsx
Normal file
35
public/app/core/components/Portal/Portal.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { PureComponent } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
root?: HTMLElement;
|
||||
}
|
||||
|
||||
export default class BodyPortal extends PureComponent<Props> {
|
||||
node: HTMLElement = document.createElement('div');
|
||||
portalRoot: HTMLElement;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const {
|
||||
className,
|
||||
root = document.body
|
||||
} = this.props;
|
||||
|
||||
if (className) {
|
||||
this.node.classList.add(className);
|
||||
}
|
||||
|
||||
this.portalRoot = root;
|
||||
this.portalRoot.appendChild(this.node);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.portalRoot.removeChild(this.node);
|
||||
}
|
||||
|
||||
render() {
|
||||
return ReactDOM.createPortal(this.props.children, this.node);
|
||||
}
|
||||
}
|
72
public/app/core/components/Select/DataSourcePicker.tsx
Normal file
72
public/app/core/components/Select/DataSourcePicker.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
// Components
|
||||
import Select from './Select';
|
||||
|
||||
// Types
|
||||
import { DataSourceSelectItem } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
onChange: (ds: DataSourceSelectItem) => void;
|
||||
datasources: DataSourceSelectItem[];
|
||||
current: DataSourceSelectItem;
|
||||
onBlur?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export class DataSourcePicker extends PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
autoFocus: false,
|
||||
};
|
||||
|
||||
searchInput: HTMLElement;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onChange = item => {
|
||||
const ds = this.props.datasources.find(ds => ds.name === item.value);
|
||||
this.props.onChange(ds);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="gf-form-inline">
|
||||
<Select
|
||||
className="ds-picker"
|
||||
isMulti={false}
|
||||
isClearable={false}
|
||||
backspaceRemovesValue={false}
|
||||
onChange={this.onChange}
|
||||
options={options}
|
||||
autoFocus={autoFocus}
|
||||
onBlur={onBlur}
|
||||
openMenuOnFocus={true}
|
||||
maxMenuHeight={500}
|
||||
placeholder="Select datasource"
|
||||
noOptionsMessage={() => 'No datasources found'}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DataSourcePicker;
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import React from 'react';
|
||||
import { components } from '@torkelo/react-select';
|
||||
|
||||
export const IndicatorsContainer = props => {
|
||||
const isOpen = props.selectProps.menuIsOpen;
|
20
public/app/core/components/Select/NoOptionsMessage.tsx
Normal file
20
public/app/core/components/Select/NoOptionsMessage.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { components } from '@torkelo/react-select';
|
||||
import { OptionProps } from '@torkelo/react-select/lib/components/Option';
|
||||
|
||||
export interface Props {
|
||||
children: Element;
|
||||
}
|
||||
|
||||
export const NoOptionsMessage = (props: OptionProps<any>) => {
|
||||
const { children } = props;
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className="gf-form-select-box__desc-option">
|
||||
<div className="gf-form-select-box__desc-option__body">{children}</div>
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoOptionsMessage;
|
53
public/app/core/components/Select/OptionGroup.tsx
Normal file
53
public/app/core/components/Select/OptionGroup.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { GroupProps } from 'react-select/lib/components/Group';
|
||||
|
||||
interface ExtendedGroupProps extends GroupProps<any> {
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface State {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export default class OptionGroup extends PureComponent<ExtendedGroupProps, State> {
|
||||
state = {
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.selectProps) {
|
||||
const value = this.props.selectProps.value[this.props.selectProps.value.length - 1];
|
||||
|
||||
if (value && this.props.options.some(option => option.value === value)) {
|
||||
this.setState({ expanded: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(nextProps) {
|
||||
if (nextProps.selectProps.inputValue !== '') {
|
||||
this.setState({ expanded: true });
|
||||
}
|
||||
}
|
||||
|
||||
onToggleChildren = () => {
|
||||
this.setState(prevState => ({
|
||||
expanded: !prevState.expanded,
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, label } = this.props;
|
||||
const { expanded } = this.state;
|
||||
|
||||
return (
|
||||
<div className="gf-form-select-box__option-group">
|
||||
<div className="gf-form-select-box__option-group__header" onClick={this.onToggleChildren}>
|
||||
<span className="flex-grow">{label}</span>
|
||||
<i className={`fa ${expanded ? 'fa-caret-left' : 'fa-caret-down'}`} />{' '}
|
||||
</div>
|
||||
{expanded && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import PickerOption from './PickerOption';
|
||||
|
||||
@ -24,7 +24,7 @@ const model = {
|
||||
children: 'Model title',
|
||||
data: {
|
||||
title: 'Model title',
|
||||
avatarUrl: 'url/to/avatar',
|
||||
imgUrl: 'url/to/avatar',
|
||||
label: 'User picker label',
|
||||
},
|
||||
className: 'class-for-user-picker',
|
44
public/app/core/components/Select/PickerOption.tsx
Normal file
44
public/app/core/components/Select/PickerOption.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { components } from '@torkelo/react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
|
||||
// https://github.com/JedWatson/react-select/issues/3038
|
||||
interface ExtendedOptionProps extends OptionProps<any> {
|
||||
data: {
|
||||
description?: string;
|
||||
imgUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const Option = (props: ExtendedOptionProps) => {
|
||||
const { children, isSelected, data } = props;
|
||||
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className="gf-form-select-box__desc-option">
|
||||
{data.imgUrl && <img className="gf-form-select-box__desc-option__img" src={data.imgUrl} />}
|
||||
<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>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
@ -1,4 +1,4 @@
|
||||
export default {
|
||||
export default {
|
||||
clearIndicator: () => ({}),
|
||||
container: () => ({}),
|
||||
control: () => ({}),
|
||||
@ -11,7 +11,9 @@
|
||||
loadingIndicator: () => ({}),
|
||||
loadingMessage: () => ({}),
|
||||
menu: () => ({}),
|
||||
menuList: () => ({}),
|
||||
menuList: ({ maxHeight }: { maxHeight: number }) => ({
|
||||
maxHeight,
|
||||
}),
|
||||
multiValue: () => ({}),
|
||||
multiValueLabel: () => ({}),
|
||||
multiValueRemove: () => ({}),
|
232
public/app/core/components/Select/Select.tsx
Normal file
232
public/app/core/components/Select/Select.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
// Libraries
|
||||
import classNames from 'classnames';
|
||||
import React, { PureComponent } from 'react';
|
||||
import { default as ReactSelect } from '@torkelo/react-select';
|
||||
import { default as ReactAsyncSelect } from '@torkelo/react-select/lib/Async';
|
||||
import { components } from '@torkelo/react-select';
|
||||
|
||||
// Components
|
||||
import { Option, SingleValue } from './PickerOption';
|
||||
import OptionGroup from './OptionGroup';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
import ResetStyles from './ResetStyles';
|
||||
import CustomScrollbar from '../CustomScrollbar/CustomScrollbar';
|
||||
|
||||
export interface SelectOptionItem {
|
||||
label?: string;
|
||||
value?: any;
|
||||
imgUrl?: string;
|
||||
description?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface CommonProps {
|
||||
defaultValue?: any;
|
||||
getOptionLabel?: (item: SelectOptionItem) => string;
|
||||
getOptionValue?: (item: SelectOptionItem) => string;
|
||||
onChange: (item: SelectOptionItem) => {} | void;
|
||||
placeholder?: string;
|
||||
width?: number;
|
||||
value?: SelectOptionItem;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
isSearchable?: boolean;
|
||||
isClearable?: boolean;
|
||||
autoFocus?: boolean;
|
||||
openMenuOnFocus?: boolean;
|
||||
onBlur?: () => void;
|
||||
maxMenuHeight?: number;
|
||||
isLoading: boolean;
|
||||
noOptionsMessage?: () => string;
|
||||
isMulti?: boolean;
|
||||
backspaceRemovesValue: boolean;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
options: SelectOptionItem[];
|
||||
}
|
||||
|
||||
interface AsyncProps {
|
||||
defaultOptions: boolean;
|
||||
loadOptions: (query: string) => Promise<SelectOptionItem[]>;
|
||||
loadingMessage?: () => string;
|
||||
}
|
||||
|
||||
export const MenuList = props => {
|
||||
return (
|
||||
<components.MenuList {...props}>
|
||||
<CustomScrollbar autoHide={false}>{props.children}</CustomScrollbar>
|
||||
</components.MenuList>
|
||||
);
|
||||
};
|
||||
|
||||
export class Select extends PureComponent<CommonProps & SelectProps> {
|
||||
static defaultProps = {
|
||||
width: null,
|
||||
className: '',
|
||||
isDisabled: false,
|
||||
isSearchable: true,
|
||||
isClearable: false,
|
||||
isMulti: false,
|
||||
openMenuOnFocus: false,
|
||||
autoFocus: false,
|
||||
isLoading: false,
|
||||
backspaceRemovesValue: true,
|
||||
maxMenuHeight: 300,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
defaultValue,
|
||||
getOptionLabel,
|
||||
getOptionValue,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
width,
|
||||
value,
|
||||
className,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
isSearchable,
|
||||
isClearable,
|
||||
backspaceRemovesValue,
|
||||
isMulti,
|
||||
autoFocus,
|
||||
openMenuOnFocus,
|
||||
onBlur,
|
||||
maxMenuHeight,
|
||||
noOptionsMessage,
|
||||
} = this.props;
|
||||
|
||||
let widthClass = '';
|
||||
if (width) {
|
||||
widthClass = 'width-' + width;
|
||||
}
|
||||
|
||||
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
|
||||
|
||||
return (
|
||||
<ReactSelect
|
||||
classNamePrefix="gf-form-select-box"
|
||||
className={selectClassNames}
|
||||
components={{
|
||||
Option,
|
||||
SingleValue,
|
||||
IndicatorsContainer,
|
||||
MenuList,
|
||||
Group: OptionGroup,
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
getOptionLabel={getOptionLabel}
|
||||
getOptionValue={getOptionValue}
|
||||
menuShouldScrollIntoView={false}
|
||||
isSearchable={isSearchable}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
placeholder={placeholder || 'Choose'}
|
||||
styles={ResetStyles}
|
||||
isDisabled={isDisabled}
|
||||
isLoading={isLoading}
|
||||
isClearable={isClearable}
|
||||
autoFocus={autoFocus}
|
||||
onBlur={onBlur}
|
||||
openMenuOnFocus={openMenuOnFocus}
|
||||
maxMenuHeight={maxMenuHeight}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
isMulti={isMulti}
|
||||
backspaceRemovesValue={backspaceRemovesValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
|
||||
static defaultProps = {
|
||||
width: null,
|
||||
className: '',
|
||||
components: {},
|
||||
loadingMessage: () => 'Loading...',
|
||||
isDisabled: false,
|
||||
isClearable: false,
|
||||
isMulti: false,
|
||||
isSearchable: true,
|
||||
backspaceRemovesValue: true,
|
||||
autoFocus: false,
|
||||
openMenuOnFocus: false,
|
||||
maxMenuHeight: 300,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
defaultValue,
|
||||
getOptionLabel,
|
||||
getOptionValue,
|
||||
onChange,
|
||||
placeholder,
|
||||
width,
|
||||
value,
|
||||
className,
|
||||
loadOptions,
|
||||
defaultOptions,
|
||||
isLoading,
|
||||
loadingMessage,
|
||||
noOptionsMessage,
|
||||
isDisabled,
|
||||
isSearchable,
|
||||
isClearable,
|
||||
backspaceRemovesValue,
|
||||
autoFocus,
|
||||
onBlur,
|
||||
openMenuOnFocus,
|
||||
maxMenuHeight,
|
||||
isMulti,
|
||||
} = this.props;
|
||||
|
||||
let widthClass = '';
|
||||
if (width) {
|
||||
widthClass = 'width-' + width;
|
||||
}
|
||||
|
||||
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
|
||||
|
||||
return (
|
||||
<ReactAsyncSelect
|
||||
classNamePrefix="gf-form-select-box"
|
||||
className={selectClassNames}
|
||||
components={{
|
||||
Option,
|
||||
SingleValue,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
getOptionLabel={getOptionLabel}
|
||||
getOptionValue={getOptionValue}
|
||||
menuShouldScrollIntoView={false}
|
||||
onChange={onChange}
|
||||
loadOptions={loadOptions}
|
||||
isLoading={isLoading}
|
||||
defaultOptions={defaultOptions}
|
||||
placeholder={placeholder || 'Choose'}
|
||||
styles={ResetStyles}
|
||||
loadingMessage={loadingMessage}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
isDisabled={isDisabled}
|
||||
isSearchable={isSearchable}
|
||||
isClearable={isClearable}
|
||||
autoFocus={autoFocus}
|
||||
onBlur={onBlur}
|
||||
openMenuOnFocus={openMenuOnFocus}
|
||||
maxMenuHeight={maxMenuHeight}
|
||||
isMulti={isMulti}
|
||||
backspaceRemovesValue={backspaceRemovesValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Select;
|
@ -1,11 +1,7 @@
|
||||
import React, { Component } from 'react';
|
||||
import AsyncSelect from 'react-select/lib/Async';
|
||||
import PickerOption from './PickerOption';
|
||||
import { AsyncSelect } from './Select';
|
||||
import { debounce } from 'lodash';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import ResetStyles from './ResetStyles';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
@ -45,9 +41,10 @@ export class TeamPicker extends Component<Props, State> {
|
||||
const teams = result.teams.map(team => {
|
||||
return {
|
||||
id: team.id,
|
||||
value: team.id,
|
||||
label: team.name,
|
||||
name: team.name,
|
||||
avatarUrl: team.avatarUrl,
|
||||
imgUrl: team.avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
@ -62,24 +59,13 @@ export class TeamPicker extends Component<Props, State> {
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<AsyncSelect
|
||||
classNamePrefix={`gf-form-select-box`}
|
||||
isMulti={false}
|
||||
isLoading={isLoading}
|
||||
defaultOptions={true}
|
||||
loadOptions={this.debouncedSearch}
|
||||
onChange={onSelected}
|
||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
styles={ResetStyles}
|
||||
components={{
|
||||
Option: PickerOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
}}
|
||||
className={className}
|
||||
placeholder="Select a team"
|
||||
loadingMessage={() => 'Loading...'}
|
||||
noOptionsMessage={() => 'No teams found'}
|
||||
getOptionValue={i => i.id}
|
||||
getOptionLabel={i => i.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
51
public/app/core/components/Select/UnitPicker.tsx
Normal file
51
public/app/core/components/Select/UnitPicker.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import Select from './Select';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
|
||||
interface Props {
|
||||
onChange: (item: any) => {} | void;
|
||||
defaultValue?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export default class UnitPicker extends PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
width: 12,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { defaultValue, onChange, width } = this.props;
|
||||
|
||||
const unitGroups = kbn.getUnitFormats();
|
||||
|
||||
// Need to transform the data structure to work well with Select
|
||||
const groupOptions = unitGroups.map(group => {
|
||||
const options = group.submenu.map(unit => {
|
||||
return {
|
||||
label: unit.text,
|
||||
value: unit.value,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
label: group.text,
|
||||
options,
|
||||
};
|
||||
});
|
||||
|
||||
const value = groupOptions.map(group => {
|
||||
return group.options.find(option => option.value === defaultValue);
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
width={width}
|
||||
defaultValue={value}
|
||||
isSearchable={true}
|
||||
options={groupOptions}
|
||||
placeholder="Choose"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,12 +1,15 @@
|
||||
// Libraries
|
||||
import React, { Component } from 'react';
|
||||
import AsyncSelect from 'react-select/lib/Async';
|
||||
import PickerOption from './PickerOption';
|
||||
|
||||
// Components
|
||||
import { AsyncSelect } from './Select';
|
||||
|
||||
// Utils & Services
|
||||
import { debounce } from 'lodash';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
// Types
|
||||
import { User } from 'app/types';
|
||||
import ResetStyles from './ResetStyles';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
|
||||
export interface Props {
|
||||
onSelected: (user: User) => void;
|
||||
@ -40,8 +43,9 @@ export class UserPicker extends Component<Props, State> {
|
||||
.then(result => {
|
||||
return result.map(user => ({
|
||||
id: user.userId,
|
||||
value: user.userId,
|
||||
label: user.login === user.email ? user.login : `${user.login} - ${user.email}`,
|
||||
avatarUrl: user.avatarUrl,
|
||||
imgUrl: user.avatarUrl,
|
||||
login: user.login,
|
||||
}));
|
||||
})
|
||||
@ -57,24 +61,13 @@ export class UserPicker extends Component<Props, State> {
|
||||
return (
|
||||
<div className="user-picker">
|
||||
<AsyncSelect
|
||||
classNamePrefix={`gf-form-select-box`}
|
||||
isMulti={false}
|
||||
className={className}
|
||||
isLoading={isLoading}
|
||||
defaultOptions={true}
|
||||
loadOptions={this.debouncedSearch}
|
||||
onChange={onSelected}
|
||||
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
|
||||
styles={ResetStyles}
|
||||
components={{
|
||||
Option: PickerOption,
|
||||
IndicatorsContainer,
|
||||
NoOptionsMessage,
|
||||
}}
|
||||
placeholder="Select user"
|
||||
loadingMessage={() => 'Loading...'}
|
||||
noOptionsMessage={() => 'No users found'}
|
||||
getOptionValue={i => i.id}
|
||||
getOptionLabel={i => i.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
@ -0,0 +1,21 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PickerOption renders correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
className="gf-form-select-box__desc-option"
|
||||
>
|
||||
<img
|
||||
className="gf-form-select-box__desc-option__img"
|
||||
src="url/to/avatar"
|
||||
/>
|
||||
<div
|
||||
className="gf-form-select-box__desc-option__body"
|
||||
>
|
||||
<div>
|
||||
Model title
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -57,35 +57,6 @@ exports[`TeamPicker renders correctly 1`] = `
|
||||
}
|
||||
}
|
||||
tabIndex="0"
|
||||
theme={
|
||||
Object {
|
||||
"borderRadius": 4,
|
||||
"colors": Object {
|
||||
"danger": "#DE350B",
|
||||
"dangerLight": "#FFBDAD",
|
||||
"neutral0": "hsl(0, 0%, 100%)",
|
||||
"neutral10": "hsl(0, 0%, 90%)",
|
||||
"neutral20": "hsl(0, 0%, 80%)",
|
||||
"neutral30": "hsl(0, 0%, 70%)",
|
||||
"neutral40": "hsl(0, 0%, 60%)",
|
||||
"neutral5": "hsl(0, 0%, 95%)",
|
||||
"neutral50": "hsl(0, 0%, 50%)",
|
||||
"neutral60": "hsl(0, 0%, 40%)",
|
||||
"neutral70": "hsl(0, 0%, 30%)",
|
||||
"neutral80": "hsl(0, 0%, 20%)",
|
||||
"neutral90": "hsl(0, 0%, 10%)",
|
||||
"primary": "#2684FF",
|
||||
"primary25": "#DEEBFF",
|
||||
"primary50": "#B2D4FF",
|
||||
"primary75": "#4C9AFF",
|
||||
},
|
||||
"spacing": Object {
|
||||
"baseUnit": 4,
|
||||
"controlHeight": 38,
|
||||
"menuGutter": 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
@ -57,35 +57,6 @@ exports[`UserPicker renders correctly 1`] = `
|
||||
}
|
||||
}
|
||||
tabIndex="0"
|
||||
theme={
|
||||
Object {
|
||||
"borderRadius": 4,
|
||||
"colors": Object {
|
||||
"danger": "#DE350B",
|
||||
"dangerLight": "#FFBDAD",
|
||||
"neutral0": "hsl(0, 0%, 100%)",
|
||||
"neutral10": "hsl(0, 0%, 90%)",
|
||||
"neutral20": "hsl(0, 0%, 80%)",
|
||||
"neutral30": "hsl(0, 0%, 70%)",
|
||||
"neutral40": "hsl(0, 0%, 60%)",
|
||||
"neutral5": "hsl(0, 0%, 95%)",
|
||||
"neutral50": "hsl(0, 0%, 50%)",
|
||||
"neutral60": "hsl(0, 0%, 40%)",
|
||||
"neutral70": "hsl(0, 0%, 30%)",
|
||||
"neutral80": "hsl(0, 0%, 20%)",
|
||||
"neutral90": "hsl(0, 0%, 10%)",
|
||||
"primary": "#2684FF",
|
||||
"primary25": "#DEEBFF",
|
||||
"primary50": "#B2D4FF",
|
||||
"primary75": "#4C9AFF",
|
||||
},
|
||||
"spacing": Object {
|
||||
"baseUnit": 4,
|
||||
"controlHeight": 38,
|
||||
"menuGutter": 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
@ -1,7 +1,7 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { Label } from 'app/core/components/Label/Label';
|
||||
import SimplePicker from 'app/core/components/Picker/SimplePicker';
|
||||
import Select from 'app/core/components/Select/Select';
|
||||
import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
|
||||
|
||||
import { DashboardSearchHit } from 'app/types';
|
||||
@ -17,12 +17,12 @@ export interface State {
|
||||
dashboards: DashboardSearchHit[];
|
||||
}
|
||||
|
||||
const themes = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }];
|
||||
const themes = [{ value: '', label: 'Default' }, { value: 'dark', label: 'Dark' }, { value: 'light', label: 'Light' }];
|
||||
|
||||
const timezones = [
|
||||
{ value: '', text: 'Default' },
|
||||
{ value: 'browser', text: 'Local browser time' },
|
||||
{ value: 'utc', text: 'UTC' },
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'browser', label: 'Local browser time' },
|
||||
{ value: 'utc', label: 'UTC' },
|
||||
];
|
||||
|
||||
export class SharedPreferences extends PureComponent<Props, State> {
|
||||
@ -91,12 +91,11 @@ export class SharedPreferences extends PureComponent<Props, State> {
|
||||
<h3 className="page-heading">Preferences</h3>
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label width-11">UI Theme</span>
|
||||
<SimplePicker
|
||||
<Select
|
||||
isSearchable={false}
|
||||
value={themes.find(item => item.value === theme)}
|
||||
options={themes}
|
||||
getOptionValue={i => i.value}
|
||||
getOptionLabel={i => i.text}
|
||||
onSelected={theme => this.onThemeChanged(theme.value)}
|
||||
onChange={theme => this.onThemeChanged(theme.value)}
|
||||
width={20}
|
||||
/>
|
||||
</div>
|
||||
@ -107,11 +106,11 @@ export class SharedPreferences extends PureComponent<Props, State> {
|
||||
>
|
||||
Home Dashboard
|
||||
</Label>
|
||||
<SimplePicker
|
||||
<Select
|
||||
value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
|
||||
getOptionValue={i => i.id}
|
||||
getOptionLabel={i => i.title}
|
||||
onSelected={(dashboard: DashboardSearchHit) => this.onHomeDashboardChanged(dashboard.id)}
|
||||
onChange={(dashboard: DashboardSearchHit) => this.onHomeDashboardChanged(dashboard.id)}
|
||||
options={dashboards}
|
||||
placeholder="Chose default dashboard"
|
||||
width={20}
|
||||
@ -119,11 +118,10 @@ export class SharedPreferences extends PureComponent<Props, State> {
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<label className="gf-form-label width-11">Timezone</label>
|
||||
<SimplePicker
|
||||
<Select
|
||||
isSearchable={false}
|
||||
value={timezones.find(item => item.value === timezone)}
|
||||
getOptionValue={i => i.value}
|
||||
getOptionLabel={i => i.text}
|
||||
onSelected={timezone => this.onTimeZoneChanged(timezone.value)}
|
||||
onChange={timezone => this.onTimeZoneChanged(timezone.value)}
|
||||
options={timezones}
|
||||
width={20}
|
||||
/>
|
||||
|
@ -5,8 +5,8 @@ export interface Props {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
labelClass?: string;
|
||||
small?: boolean;
|
||||
switchClass?: string;
|
||||
transparent?: boolean;
|
||||
onChange: (event) => any;
|
||||
}
|
||||
|
||||
@ -25,27 +25,20 @@ export class Switch extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { labelClass = '', switchClass = '', label, checked, small } = this.props;
|
||||
const { labelClass = '', switchClass = '', label, checked, transparent } = this.props;
|
||||
|
||||
const labelId = `check-${this.state.id}`;
|
||||
let labelClassName = `gf-form-label ${labelClass} pointer`;
|
||||
let switchClassName = `gf-form-switch ${switchClass}`;
|
||||
if (small) {
|
||||
labelClassName += ' gf-form-label--small';
|
||||
switchClassName += ' gf-form-switch--small';
|
||||
}
|
||||
const labelClassName = `gf-form-label ${labelClass} ${transparent ? 'gf-form-label--transparent' : ''} pointer`;
|
||||
const switchClassName = `gf-form-switch ${switchClass} ${transparent ? 'gf-form-switch--transparent' : ''}`;
|
||||
|
||||
return (
|
||||
<div className="gf-form">
|
||||
{label && (
|
||||
<label htmlFor={labelId} className={labelClassName}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<label htmlFor={labelId} className="gf-form-switch-container">
|
||||
{label && <div className={labelClassName}>{label}</div>}
|
||||
<div className={switchClassName}>
|
||||
<input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />
|
||||
<label htmlFor={labelId} />
|
||||
<span className="gf-form-switch__slider" />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import AsyncSelect from 'react-select/lib/Async';
|
||||
import AsyncSelect from '@torkelo/react-select/lib/Async';
|
||||
|
||||
import { TagOption } from './TagOption';
|
||||
import { TagBadge } from './TagBadge';
|
||||
import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
|
||||
import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
|
||||
import { components } from 'react-select';
|
||||
import ResetStyles from 'app/core/components/Picker/ResetStyles';
|
||||
import IndicatorsContainer from 'app/core/components/Select/IndicatorsContainer';
|
||||
import NoOptionsMessage from 'app/core/components/Select/NoOptionsMessage';
|
||||
import { components } from '@torkelo/react-select';
|
||||
import ResetStyles from 'app/core/components/Select/ResetStyles';
|
||||
|
||||
export interface Props {
|
||||
tags: string[];
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { components } from 'react-select';
|
||||
import { components } from '@torkelo/react-select';
|
||||
import { OptionProps } from 'react-select/lib/components/Option';
|
||||
import { TagBadge } from './TagBadge';
|
||||
|
||||
|
@ -1,43 +1,20 @@
|
||||
import React, { SFC, ReactNode, PureComponent, ReactElement } from 'react';
|
||||
import React, { SFC, ReactNode, PureComponent } from 'react';
|
||||
import Tooltip from 'app/core/components/Tooltip/Tooltip';
|
||||
|
||||
interface ToggleButtonGroupProps {
|
||||
onChange: (value) => void;
|
||||
value?: any;
|
||||
label?: string;
|
||||
render: (props) => void;
|
||||
children: JSX.Element[];
|
||||
transparent?: boolean;
|
||||
}
|
||||
|
||||
export default class ToggleButtonGroup extends PureComponent<ToggleButtonGroupProps> {
|
||||
getValues() {
|
||||
const { children } = this.props;
|
||||
return React.Children.toArray(children).map((c: ReactElement<any>) => c.props.value);
|
||||
}
|
||||
|
||||
smallChildren() {
|
||||
const { children } = this.props;
|
||||
return React.Children.toArray(children).every((c: ReactElement<any>) => c.props.className.includes('small'));
|
||||
}
|
||||
|
||||
handleToggle(toggleValue) {
|
||||
const { value, onChange } = this.props;
|
||||
if (value && value === toggleValue) {
|
||||
return;
|
||||
}
|
||||
onChange(toggleValue);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, label } = this.props;
|
||||
const values = this.getValues();
|
||||
const selectedValue = value || values[0];
|
||||
const labelClassName = `gf-form-label ${this.smallChildren() ? 'small' : ''}`;
|
||||
const { children, label, transparent } = this.props;
|
||||
|
||||
return (
|
||||
<div className="gf-form">
|
||||
<div className="toggle-button-group">
|
||||
{label && <label className={labelClassName}>{label}</label>}
|
||||
{this.props.render({ selectedValue, onChange: this.handleToggle.bind(this) })}
|
||||
</div>
|
||||
{label && <label className={`gf-form-label ${transparent ? 'gf-form-label--transparent' : ''}`}>{label}</label>}
|
||||
<div className={`toggle-button-group ${transparent ? 'toggle-button-group--transparent' : ''}`}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -49,15 +26,15 @@ interface ToggleButtonProps {
|
||||
value: any;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export const ToggleButton: SFC<ToggleButtonProps> = ({
|
||||
children,
|
||||
selected,
|
||||
className = '',
|
||||
title = null,
|
||||
value,
|
||||
value = null,
|
||||
tooltip,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = event => {
|
||||
@ -68,9 +45,15 @@ export const ToggleButton: SFC<ToggleButtonProps> = ({
|
||||
};
|
||||
|
||||
const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
|
||||
return (
|
||||
<button className={btnClassName} onClick={handleChange} title={title}>
|
||||
const button = (
|
||||
<button className={btnClassName} onClick={handleChange}>
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return <Tooltip content={tooltip}>{button}</Tooltip>;
|
||||
} else {
|
||||
return button;
|
||||
}
|
||||
};
|
||||
|
@ -1,34 +1,19 @@
|
||||
import React from 'react';
|
||||
import withTooltip from './withTooltip';
|
||||
import { Target } from 'react-popper';
|
||||
|
||||
interface PopoverProps {
|
||||
tooltipSetState: (prevState: object) => void;
|
||||
}
|
||||
|
||||
class Popover extends React.Component<PopoverProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.toggleTooltip = this.toggleTooltip.bind(this);
|
||||
}
|
||||
|
||||
toggleTooltip() {
|
||||
const { tooltipSetState } = this.props;
|
||||
tooltipSetState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
show: !prevState.show,
|
||||
};
|
||||
});
|
||||
}
|
||||
import React, { PureComponent } from 'react';
|
||||
import Popper from './Popper';
|
||||
import withPopper, { UsingPopperProps } from './withPopper';
|
||||
|
||||
class Popover extends PureComponent<UsingPopperProps> {
|
||||
render() {
|
||||
const { children, hidePopper, showPopper, className, ...restProps } = this.props;
|
||||
|
||||
const togglePopper = restProps.show ? hidePopper : showPopper;
|
||||
|
||||
return (
|
||||
<Target className="popper__target" onClick={this.toggleTooltip}>
|
||||
{this.props.children}
|
||||
</Target>
|
||||
<div className={`popper__manager ${className}`} onClick={togglePopper}>
|
||||
<Popper {...restProps}>{children}</Popper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTooltip(Popover);
|
||||
export default withPopper(Popover);
|
||||
|
72
public/app/core/components/Tooltip/Popper.tsx
Normal file
72
public/app/core/components/Tooltip/Popper.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import Portal from 'app/core/components/Portal/Portal';
|
||||
import { Manager, Popper as ReactPopper, Reference } from 'react-popper';
|
||||
import Transition from 'react-transition-group/Transition';
|
||||
|
||||
const defaultTransitionStyles = {
|
||||
transition: 'opacity 200ms linear',
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
const transitionStyles = {
|
||||
exited: { opacity: 0 },
|
||||
entering: { opacity: 0 },
|
||||
entered: { opacity: 1 },
|
||||
exiting: { opacity: 0 },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
renderContent: (content: any) => any;
|
||||
show: boolean;
|
||||
placement?: any;
|
||||
content: string | ((props: any) => JSX.Element);
|
||||
refClassName?: string;
|
||||
}
|
||||
|
||||
class Popper extends PureComponent<Props> {
|
||||
render() {
|
||||
const { children, renderContent, show, placement, refClassName } = this.props;
|
||||
const { content } = this.props;
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div className={`popper_ref ${refClassName || ''}`} ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
<Transition in={show} timeout={100} mountOnEnter={true} unmountOnExit={true}>
|
||||
{transitionState => (
|
||||
<Portal>
|
||||
<ReactPopper placement={placement}>
|
||||
{({ ref, style, placement, arrowProps }) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
...defaultTransitionStyles,
|
||||
...transitionStyles[transitionState],
|
||||
}}
|
||||
data-placement={placement}
|
||||
className="popper"
|
||||
>
|
||||
<div className="popper__background">
|
||||
{renderContent(content)}
|
||||
<div ref={arrowProps.ref} data-placement={placement} className="popper__arrow" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</ReactPopper>
|
||||
</Portal>
|
||||
)}
|
||||
</Transition>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Popper;
|
@ -1,36 +1,17 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import withTooltip from './withTooltip';
|
||||
import { Target } from 'react-popper';
|
||||
|
||||
interface Props {
|
||||
tooltipSetState: (prevState: object) => void;
|
||||
}
|
||||
|
||||
class Tooltip extends PureComponent<Props> {
|
||||
showTooltip = () => {
|
||||
const { tooltipSetState } = this.props;
|
||||
|
||||
tooltipSetState(prevState => ({
|
||||
...prevState,
|
||||
show: true,
|
||||
}));
|
||||
};
|
||||
|
||||
hideTooltip = () => {
|
||||
const { tooltipSetState } = this.props;
|
||||
tooltipSetState(prevState => ({
|
||||
...prevState,
|
||||
show: false,
|
||||
}));
|
||||
};
|
||||
import Popper from './Popper';
|
||||
import withPopper, { UsingPopperProps } from './withPopper';
|
||||
|
||||
class Tooltip extends PureComponent<UsingPopperProps> {
|
||||
render() {
|
||||
const { children, hidePopper, showPopper, className, ...restProps } = this.props;
|
||||
|
||||
return (
|
||||
<Target className="popper__target" onMouseOver={this.showTooltip} onMouseOut={this.hideTooltip}>
|
||||
{this.props.children}
|
||||
</Target>
|
||||
<div className={`popper__manager ${className}`} onMouseEnter={showPopper} onMouseLeave={hidePopper}>
|
||||
<Popper {...restProps}>{children}</Popper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withTooltip(Tooltip);
|
||||
export default withPopper(Tooltip);
|
||||
|
@ -3,10 +3,10 @@
|
||||
exports[`Popover renders correctly 1`] = `
|
||||
<div
|
||||
className="popper__manager test-class"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div
|
||||
className="popper__target"
|
||||
onClick={[Function]}
|
||||
className="popper_ref "
|
||||
>
|
||||
<button>
|
||||
Button with Popover
|
||||
|
@ -3,11 +3,11 @@
|
||||
exports[`Tooltip renders correctly 1`] = `
|
||||
<div
|
||||
className="popper__manager test-class"
|
||||
onMouseEnter={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
>
|
||||
<div
|
||||
className="popper__target"
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
className="popper_ref "
|
||||
>
|
||||
<a
|
||||
href="http://www.grafana.com"
|
||||
|
88
public/app/core/components/Tooltip/withPopper.tsx
Normal file
88
public/app/core/components/Tooltip/withPopper.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface UsingPopperProps {
|
||||
showPopper: (prevState: object) => void;
|
||||
hidePopper: (prevState: object) => void;
|
||||
renderContent: (content: any) => any;
|
||||
show: boolean;
|
||||
placement?: string;
|
||||
content: string | ((props: any) => JSX.Element);
|
||||
className?: string;
|
||||
refClassName?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
placement?: string;
|
||||
className?: string;
|
||||
refClassName?: string;
|
||||
content: string | ((props: any) => JSX.Element);
|
||||
}
|
||||
|
||||
interface State {
|
||||
placement: string;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export default function withPopper(WrappedComponent) {
|
||||
return class extends React.Component<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.setState = this.setState.bind(this);
|
||||
this.state = {
|
||||
placement: this.props.placement || 'auto',
|
||||
show: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.placement && nextProps.placement !== this.state.placement) {
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
placement: nextProps.placement,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showPopper = () => {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
show: true,
|
||||
}));
|
||||
};
|
||||
|
||||
hidePopper = () => {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
show: false,
|
||||
}));
|
||||
};
|
||||
|
||||
renderContent(content) {
|
||||
if (typeof content === 'function') {
|
||||
// If it's a function we assume it's a React component
|
||||
const ReactComponent = content;
|
||||
return <ReactComponent />;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { show, placement } = this.state;
|
||||
const className = this.props.className || '';
|
||||
|
||||
return (
|
||||
<WrappedComponent
|
||||
{...this.props}
|
||||
showPopper={this.showPopper}
|
||||
hidePopper={this.hidePopper}
|
||||
renderContent={this.renderContent}
|
||||
show={show}
|
||||
placement={placement}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Manager, Popper, Arrow } from 'react-popper';
|
||||
|
||||
interface IwithTooltipProps {
|
||||
placement?: string;
|
||||
content: string | ((props: any) => JSX.Element);
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function withTooltip(WrappedComponent) {
|
||||
return class extends React.Component<IwithTooltipProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.setState = this.setState.bind(this);
|
||||
this.state = {
|
||||
placement: this.props.placement || 'auto',
|
||||
show: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.placement && nextProps.placement !== this.state.placement) {
|
||||
this.setState(prevState => {
|
||||
return {
|
||||
...prevState,
|
||||
placement: nextProps.placement,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderContent(content) {
|
||||
if (typeof content === 'function') {
|
||||
// If it's a function we assume it's a React component
|
||||
const ReactComponent = content;
|
||||
return <ReactComponent />;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { content, className } = this.props;
|
||||
|
||||
return (
|
||||
<Manager className={`popper__manager ${className || ''}`}>
|
||||
<WrappedComponent {...this.props} tooltipSetState={this.setState} />
|
||||
{this.state.show ? (
|
||||
<Popper placement={this.state.placement} className="popper">
|
||||
{this.renderContent(content)}
|
||||
<Arrow className="popper__arrow" />
|
||||
</Popper>
|
||||
) : null}
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
@ -84,7 +84,7 @@ function link(scope, elem, attrs) {
|
||||
// disable depreacation warning
|
||||
codeEditor.$blockScrolling = Infinity;
|
||||
// Padding hacks
|
||||
(codeEditor.renderer as any).setScrollMargin(15, 15);
|
||||
(codeEditor.renderer as any).setScrollMargin(10, 10);
|
||||
codeEditor.renderer.setPadding(10);
|
||||
|
||||
setThemeMode();
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import $ from 'jquery';
|
||||
import Drop from 'tether-drop';
|
||||
import { ColorPickerPopover } from './ColorPickerPopover';
|
||||
import { react2AngularDirective } from 'app/core/utils/react2angular';
|
||||
@ -11,29 +10,17 @@ export interface Props {
|
||||
}
|
||||
|
||||
export class ColorPicker extends React.Component<Props, any> {
|
||||
pickerElem: any;
|
||||
pickerElem: HTMLElement;
|
||||
colorPickerDrop: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.openColorPicker = this.openColorPicker.bind(this);
|
||||
this.closeColorPicker = this.closeColorPicker.bind(this);
|
||||
this.setPickerElem = this.setPickerElem.bind(this);
|
||||
this.onColorSelect = this.onColorSelect.bind(this);
|
||||
}
|
||||
|
||||
setPickerElem(elem) {
|
||||
this.pickerElem = $(elem);
|
||||
}
|
||||
|
||||
openColorPicker() {
|
||||
openColorPicker = () => {
|
||||
const dropContent = <ColorPickerPopover color={this.props.color} onColorSelect={this.onColorSelect} />;
|
||||
|
||||
const dropContentElem = document.createElement('div');
|
||||
ReactDOM.render(dropContent, dropContentElem);
|
||||
|
||||
const drop = new Drop({
|
||||
target: this.pickerElem[0],
|
||||
target: this.pickerElem,
|
||||
content: dropContentElem,
|
||||
position: 'top center',
|
||||
classes: 'drop-popover',
|
||||
@ -48,23 +35,23 @@ export class ColorPicker extends React.Component<Props, any> {
|
||||
|
||||
this.colorPickerDrop = drop;
|
||||
this.colorPickerDrop.open();
|
||||
}
|
||||
};
|
||||
|
||||
closeColorPicker() {
|
||||
closeColorPicker = () => {
|
||||
setTimeout(() => {
|
||||
if (this.colorPickerDrop && this.colorPickerDrop.tether) {
|
||||
this.colorPickerDrop.destroy();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
onColorSelect(color) {
|
||||
onColorSelect = color => {
|
||||
this.props.onChange(color);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={this.setPickerElem}>
|
||||
<div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={element => (this.pickerElem = element)}>
|
||||
<div className="sp-preview">
|
||||
<div className="sp-preview-inner" style={{ backgroundColor: this.props.color }} />
|
||||
</div>
|
||||
|
@ -64,10 +64,10 @@
|
||||
|
||||
<div class="search-results" ng-show="ctrl.sections.length > 0">
|
||||
<div class="search-results-filter-row">
|
||||
<gf-form-switch
|
||||
<gf-form-checkbox
|
||||
on-change="ctrl.onSelectAllChanged()"
|
||||
checked="ctrl.selectAllChecked"
|
||||
switch-class="gf-form-switch--transparent gf-form-switch--search-result-filter-row__checkbox"
|
||||
switch-class="gf-form-checkbox--transparent"
|
||||
/>
|
||||
<div class="search-results-filter-row__filters">
|
||||
<div class="gf-form-select-wrapper" ng-show="!(ctrl.canMove || ctrl.canDelete)">
|
||||
|
@ -1,12 +1,12 @@
|
||||
<div ng-repeat="section in ctrl.results" class="search-section">
|
||||
<div class="search-section__header pointer" ng-hide="section.hideHeader" ng-class="{'selected': section.selected}" ng-click="ctrl.toggleFolderExpand(section)">
|
||||
<div ng-click="ctrl.toggleSelection(section, $event)">
|
||||
<gf-form-switch
|
||||
<div ng-click="ctrl.toggleSelection(section, $event)" class="center-vh">
|
||||
<gf-form-checkbox
|
||||
ng-show="ctrl.editable"
|
||||
on-change="ctrl.selectionChanged($event)"
|
||||
checked="section.checked"
|
||||
switch-class="gf-form-switch--transparent gf-form-switch--search-result__section">
|
||||
</gf-form-switch>
|
||||
switch-class="gf-form-checkbox--transparent">
|
||||
</gf-form-checkbox>
|
||||
</div>
|
||||
<i class="search-section__header__icon" ng-class="section.icon"></i>
|
||||
<span class="search-section__header__text">{{::section.title}}</span>
|
||||
@ -21,13 +21,13 @@
|
||||
|
||||
<div ng-if="section.expanded">
|
||||
<a ng-repeat="item in section.items" class="search-item search-item--indent" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" >
|
||||
<div ng-click="ctrl.toggleSelection(item, $event)">
|
||||
<gf-form-switch
|
||||
<div ng-click="ctrl.toggleSelection(item, $event)" class="center-vh">
|
||||
<gf-form-checkbox
|
||||
ng-show="ctrl.editable"
|
||||
on-change="ctrl.selectionChanged()"
|
||||
checked="item.checked"
|
||||
switch-class="gf-form-switch--transparent gf-form-switch--search-result__item">
|
||||
</gf-form-switch>
|
||||
switch-class="gf-form-checkbox--transparent">
|
||||
</gf-form-checkbox>
|
||||
</div>
|
||||
<span class="search-item__icon">
|
||||
<i class="gicon mini gicon-dashboard-list"></i>
|
||||
|
@ -1,16 +1,33 @@
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
const template = `
|
||||
<label for="check-{{ctrl.id}}" class="gf-form-label {{ctrl.labelClass}} pointer" ng-show="ctrl.label">
|
||||
{{ctrl.label}}
|
||||
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
|
||||
{{ctrl.tooltip}}
|
||||
</info-popover>
|
||||
<label for="check-{{ctrl.id}}" class="gf-form-switch-container">
|
||||
<div class="gf-form-label {{ctrl.labelClass}}" ng-show="ctrl.label">
|
||||
{{ctrl.label}}
|
||||
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
|
||||
{{ctrl.tooltip}}
|
||||
</info-popover>
|
||||
</div>
|
||||
<div class="gf-form-switch {{ctrl.switchClass}}" ng-if="ctrl.show">
|
||||
<input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
|
||||
<span class="gf-form-switch__slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
|
||||
const checkboxTemplate = `
|
||||
<label for="check-{{ctrl.id}}" class="gf-form-switch-container">
|
||||
<div class="gf-form-label {{ctrl.labelClass}}" ng-show="ctrl.label">
|
||||
{{ctrl.label}}
|
||||
<info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
|
||||
{{ctrl.tooltip}}
|
||||
</info-popover>
|
||||
</div>
|
||||
<div class="gf-form-checkbox {{ctrl.switchClass}}" ng-if="ctrl.show">
|
||||
<input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
|
||||
<span class="gf-form-switch__checkbox"></span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="gf-form-switch {{ctrl.switchClass}}" ng-if="ctrl.show">
|
||||
<input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
|
||||
<label for="check-{{ctrl.id}}" data-on="Yes" data-off="No"></label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export class SwitchCtrl {
|
||||
@ -51,4 +68,23 @@ export function switchDirective() {
|
||||
};
|
||||
}
|
||||
|
||||
export function checkboxDirective() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
controller: SwitchCtrl,
|
||||
controllerAs: 'ctrl',
|
||||
bindToController: true,
|
||||
scope: {
|
||||
checked: '=',
|
||||
label: '@',
|
||||
labelClass: '@',
|
||||
tooltip: '@',
|
||||
switchClass: '@',
|
||||
onChange: '&',
|
||||
},
|
||||
template: checkboxTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('gfFormSwitch', switchDirective);
|
||||
coreModule.directive('gfFormCheckbox', checkboxDirective);
|
||||
|
@ -11,3 +11,7 @@ export const LS_PANEL_COPY_KEY = 'panel-copy';
|
||||
|
||||
export const DASHBOARD_TOOLBAR_HEIGHT = 55;
|
||||
export const DASHBOARD_TOP_PADDING = 20;
|
||||
|
||||
export const PANEL_HEADER_HEIGHT = 27;
|
||||
export const PANEL_BORDER = 2;
|
||||
export const PANEL_OPTIONS_KEY_PREFIX = 'options-';
|
||||
|
@ -1,3 +1,4 @@
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import coreModule from '../core_module';
|
||||
|
||||
@ -5,18 +6,20 @@ import coreModule from '../core_module';
|
||||
function dashClass($timeout) {
|
||||
return {
|
||||
link: ($scope, elem) => {
|
||||
const body = $('body');
|
||||
|
||||
$scope.ctrl.dashboard.events.on('view-mode-changed', panel => {
|
||||
console.log('view-mode-changed', panel.fullscreen);
|
||||
if (panel.fullscreen) {
|
||||
elem.addClass('panel-in-fullscreen');
|
||||
body.addClass('panel-in-fullscreen');
|
||||
} else {
|
||||
$timeout(() => {
|
||||
elem.removeClass('panel-in-fullscreen');
|
||||
body.removeClass('panel-in-fullscreen');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
elem.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
|
||||
body.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
|
||||
|
||||
$scope.$watch('ctrl.dashboardViewState.state.editview', newValue => {
|
||||
if (newValue) {
|
||||
|
@ -12,7 +12,7 @@ export function dropdownTypeahead($compile) {
|
||||
const buttonTemplate =
|
||||
'<a class="gf-form-label tight-form-func dropdown-toggle"' +
|
||||
' tabindex="1" gf-dropdown="menuItems" data-toggle="dropdown"' +
|
||||
' data-placement="top"><i class="fa fa-plus"></i></a>';
|
||||
' ><i class="fa fa-plus"></i></a>';
|
||||
|
||||
return {
|
||||
scope: {
|
||||
@ -130,7 +130,7 @@ export function dropdownTypeahead2($compile) {
|
||||
const buttonTemplate =
|
||||
'<a class="gf-form-input dropdown-toggle"' +
|
||||
' tabindex="1" gf-dropdown="menuItems" data-toggle="dropdown"' +
|
||||
' data-placement="top"><i class="fa fa-plus"></i></a>';
|
||||
' ><i class="fa fa-plus"></i></a>';
|
||||
|
||||
return {
|
||||
scope: {
|
||||
|
@ -18,6 +18,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
|
||||
|
||||
if (action.payload.partial) {
|
||||
query = _.defaults(query, state.query);
|
||||
query = _.omitBy(query, _.isNull);
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -4,6 +4,8 @@ import _ from 'lodash';
|
||||
|
||||
export interface AngularComponent {
|
||||
destroy();
|
||||
digest();
|
||||
getScope();
|
||||
}
|
||||
|
||||
export class AngularLoader {
|
||||
@ -24,6 +26,12 @@ export class AngularLoader {
|
||||
scope.$destroy();
|
||||
compiledElem.remove();
|
||||
},
|
||||
digest: () => {
|
||||
scope.$digest();
|
||||
},
|
||||
getScope: () => {
|
||||
return scope;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import coreModule from 'app/core/core_module';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { store } from 'app/store/configureStore';
|
||||
import { store } from 'app/store/store';
|
||||
import locationUtil from 'app/core/utils/location_util';
|
||||
import { updateLocation } from 'app/core/actions';
|
||||
|
||||
|
@ -86,11 +86,10 @@ export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]):
|
||||
if (arguments.length === 1) {
|
||||
return model;
|
||||
}
|
||||
|
||||
// Single query returns data columns and rows as is
|
||||
if (arguments.length === 2) {
|
||||
model.columns = [...tables[0].columns];
|
||||
model.rows = [...tables[0].rows];
|
||||
model.columns = tables[0].hasOwnProperty('columns') ? [...tables[0].columns] : [];
|
||||
model.rows = tables[0].hasOwnProperty('rows') ? [...tables[0].rows] : [];
|
||||
return model;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { store } from '../../store/configureStore';
|
||||
import { store } from '../../store/store';
|
||||
|
||||
export function connectWithStore(WrappedComponent, ...args) {
|
||||
const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
|
||||
|
@ -405,7 +405,8 @@ kbn.valueFormats.percentunit = (size, decimals) => {
|
||||
};
|
||||
|
||||
/* Formats the value to hex. Uses float if specified decimals are not 0.
|
||||
* There are two options, one with 0x, and one without */
|
||||
* There are two submenu
|
||||
* , one with 0x, and one without */
|
||||
|
||||
kbn.valueFormats.hex = (value, decimals) => {
|
||||
if (value == null) {
|
||||
|
@ -159,3 +159,12 @@ export function describeTimeRange(range: RawTimeRange): string {
|
||||
|
||||
return range.from.toString() + ' to ' + range.to.toString();
|
||||
}
|
||||
|
||||
export const isValidTimeSpan = (value: string) => {
|
||||
if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const info = describeTextRange(value);
|
||||
return info.invalid !== true;
|
||||
};
|
||||
|
16
public/app/core/utils/validate.ts
Normal file
16
public/app/core/utils/validate.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { ValidationRule, ValidationEvents } from 'app/types';
|
||||
import { EventsWithValidation } from 'app/core/components/Form/Input';
|
||||
|
||||
export const validate = (value: string, validationRules: ValidationRule[]) => {
|
||||
const errors = validationRules.reduce((acc, currRule) => {
|
||||
if (!currRule.rule(value)) {
|
||||
return acc.concat(currRule.errorMessage);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
return errors.length > 0 ? errors : null;
|
||||
};
|
||||
|
||||
export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents) => {
|
||||
return validationEvents && validationEvents[event];
|
||||
};
|
@ -1,4 +1,5 @@
|
||||
import _ from 'lodash';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import { ThresholdMapper } from './state/ThresholdMapper';
|
||||
import { QueryPart } from 'app/core/components/query_part/query_part';
|
||||
import alertDef from './state/alertDef';
|
||||
@ -261,7 +262,7 @@ export class AlertTabCtrl {
|
||||
this.datasourceSrv.get(datasourceName).then(ds => {
|
||||
if (!ds.meta.alerting) {
|
||||
this.error = 'The datasource does not support alerting queries';
|
||||
} else if (ds.targetContainsTemplate(foundTarget)) {
|
||||
} else if (ds.targetContainsTemplate && ds.targetContainsTemplate(foundTarget)) {
|
||||
this.error = 'Template variables are not supported in alert queries';
|
||||
} else {
|
||||
this.error = '';
|
||||
@ -430,3 +431,5 @@ export function alertTab() {
|
||||
controller: AlertTabCtrl,
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('alertTab', alertTab);
|
||||
|
@ -1,187 +1,191 @@
|
||||
<div class="edit-tab-with-sidemenu" ng-if="ctrl.alert">
|
||||
<aside class="edit-sidemenu-aside">
|
||||
<ul class="edit-sidemenu">
|
||||
<li ng-class="{active: ctrl.subTabIndex === 0}">
|
||||
<a ng-click="ctrl.changeTabIndex(0)">Alert Config</a>
|
||||
</li>
|
||||
<li ng-class="{active: ctrl.subTabIndex === 1}">
|
||||
<a ng-click="ctrl.changeTabIndex(1)">
|
||||
Notifications <span class="muted">({{ctrl.alertNotifications.length}})</span>
|
||||
</a>
|
||||
</li>
|
||||
<li ng-class="{active: ctrl.subTabIndex === 2}">
|
||||
<a ng-click="ctrl.changeTabIndex(2)">State history</a>
|
||||
</li>
|
||||
<li>
|
||||
<a ng-click="ctrl.delete()">Delete</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
<div class="panel-option-section__body" ng-if="ctrl.alert">
|
||||
<div class="edit-tab-with-sidemenu">
|
||||
<aside class="edit-sidemenu-aside">
|
||||
<ul class="edit-sidemenu">
|
||||
<li ng-class="{active: ctrl.subTabIndex === 0}">
|
||||
<a ng-click="ctrl.changeTabIndex(0)">Alert Config</a>
|
||||
</li>
|
||||
<li ng-class="{active: ctrl.subTabIndex === 1}">
|
||||
<a ng-click="ctrl.changeTabIndex(1)">
|
||||
Notifications <span class="muted">({{ctrl.alertNotifications.length}})</span>
|
||||
</a>
|
||||
</li>
|
||||
<li ng-class="{active: ctrl.subTabIndex === 2}">
|
||||
<a ng-click="ctrl.changeTabIndex(2)">State history</a>
|
||||
</li>
|
||||
<li>
|
||||
<a ng-click="ctrl.delete()">Delete</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="edit-tab-content">
|
||||
<div ng-if="ctrl.subTabIndex === 0">
|
||||
<div class="alert alert-error m-b-2" ng-show="ctrl.error">
|
||||
<i class="fa fa-warning"></i> {{ctrl.error}}
|
||||
</div>
|
||||
<div class="edit-tab-content">
|
||||
<div ng-if="ctrl.subTabIndex === 0">
|
||||
<div class="alert alert-error m-b-2" ng-show="ctrl.error">
|
||||
<i class="fa fa-warning"></i> {{ctrl.error}}
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group">
|
||||
<h5 class="section-heading">Alert Config</h5>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-6">Name</span>
|
||||
<input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name">
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Evaluate every</span>
|
||||
<input class="gf-form-input max-width-6" type="text" ng-model="ctrl.alert.frequency">
|
||||
</div>
|
||||
<div class="gf-form max-width-11">
|
||||
<label class="gf-form-label width-5">For</label>
|
||||
<input type="text" class="gf-form-input max-width-6" ng-model="ctrl.alert.for" spellcheck='false' placeholder="5m">
|
||||
<info-popover mode="right-absolute">
|
||||
If an alert rule has a configured For and the query violates the configured threshold it will first go from OK to Pending.
|
||||
Going from OK to Pending Grafana will not send any notifications. Once the alert rule has been firing for more than For duration, it will change to Alerting and send alert notifications.
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-group">
|
||||
<h5 class="section-heading">Alert Config</h5>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-6">Name</span>
|
||||
<input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name">
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-9">Evaluate every</span>
|
||||
<input class="gf-form-input max-width-6" type="text" ng-model="ctrl.alert.frequency">
|
||||
</div>
|
||||
<div class="gf-form max-width-11">
|
||||
<label class="gf-form-label width-5">For</label>
|
||||
<input type="text" class="gf-form-input max-width-6" ng-model="ctrl.alert.for" spellcheck='false' placeholder="5m">
|
||||
<info-popover mode="right-absolute">
|
||||
If an alert rule has a configured For and the query violates the configured threshold it will first go from OK to Pending.
|
||||
Going from OK to Pending Grafana will not send any notifications. Once the alert rule has been firing for more than For duration, it will change to Alerting and send alert notifications.
|
||||
</info-popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group">
|
||||
<h5 class="section-heading">Conditions</h5>
|
||||
<div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels">
|
||||
<div class="gf-form">
|
||||
<metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model>
|
||||
<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<query-part-editor class="gf-form-label query-part width-9" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)">
|
||||
</query-part-editor>
|
||||
<span class="gf-form-label query-keyword">OF</span>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)">
|
||||
</query-part-editor>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model>
|
||||
<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()">
|
||||
<label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label>
|
||||
<input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()">
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label">
|
||||
<a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)">
|
||||
<i class="fa fa-trash"></i>
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-group">
|
||||
<h5 class="section-heading">Conditions</h5>
|
||||
<div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels">
|
||||
<div class="gf-form">
|
||||
<metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model>
|
||||
<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<query-part-editor class="gf-form-label query-part width-9" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)">
|
||||
</query-part-editor>
|
||||
<span class="gf-form-label query-keyword">OF</span>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)">
|
||||
</query-part-editor>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model>
|
||||
<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()">
|
||||
<label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label>
|
||||
<input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()">
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label">
|
||||
<a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)">
|
||||
<i class="fa fa-trash"></i>
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label dropdown">
|
||||
<a class="pointer dropdown-toggle" data-toggle="dropdown">
|
||||
<i class="fa fa-plus"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li ng-repeat="ct in ctrl.conditionTypes" role="menuitem">
|
||||
<a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label dropdown">
|
||||
<a class="pointer dropdown-toggle" data-toggle="dropdown">
|
||||
<i class="fa fa-plus"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li ng-repeat="ct in ctrl.conditionTypes" role="menuitem">
|
||||
<a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-18">If no data or all values are null</span>
|
||||
<span class="gf-form-label query-keyword">SET STATE TO</span>
|
||||
<div class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input" ng-model="ctrl.alert.noDataState" ng-options="f.value as f.text for f in ctrl.noDataModes">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-18">If no data or all values are null</span>
|
||||
<span class="gf-form-label query-keyword">SET STATE TO</span>
|
||||
<div class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input" ng-model="ctrl.alert.noDataState" ng-options="f.value as f.text for f in ctrl.noDataModes">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-18">If execution error or timeout</span>
|
||||
<span class="gf-form-label query-keyword">SET STATE TO</span>
|
||||
<div class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input" ng-model="ctrl.alert.executionErrorState" ng-options="f.value as f.text for f in ctrl.executionErrorModes">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label width-18">If execution error or timeout</span>
|
||||
<span class="gf-form-label query-keyword">SET STATE TO</span>
|
||||
<div class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input" ng-model="ctrl.alert.executionErrorState" ng-options="f.value as f.text for f in ctrl.executionErrorModes">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-button-row">
|
||||
<button class="btn btn-inverse" ng-click="ctrl.test()">
|
||||
Test Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-button-row">
|
||||
<button class="btn btn-inverse" ng-click="ctrl.test()">
|
||||
Test Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group" ng-if="ctrl.testing">
|
||||
Evaluating rule <i class="fa fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
<div class="gf-form-group" ng-if="ctrl.testing">
|
||||
Evaluating rule <i class="fa fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group" ng-if="ctrl.testResult">
|
||||
<json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-group" ng-if="ctrl.testResult">
|
||||
<json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group" ng-if="ctrl.subTabIndex === 1">
|
||||
<h5 class="section-heading">Notifications</h5>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form max-width-30">
|
||||
<span class="gf-form-label width-8">Send to</span>
|
||||
<span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications" ng-style="{'background-color': nc.bgColor }">
|
||||
<i class="{{nc.iconClass}}"></i> {{nc.name}}
|
||||
<i class="fa fa-remove pointer muted" ng-click="ctrl.removeNotification($index)" ng-if="nc.isDefault === false"></i>
|
||||
</span>
|
||||
<metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form gf-form--v-stretch">
|
||||
<span class="gf-form-label width-8">Message</span>
|
||||
<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message" placeholder="Notification message details..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-group" ng-if="ctrl.subTabIndex === 1">
|
||||
<h5 class="section-heading">Notifications</h5>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form max-width-30">
|
||||
<span class="gf-form-label width-8">Send to</span>
|
||||
<span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications" ng-style="{'background-color': nc.bgColor }">
|
||||
<i class="{{nc.iconClass}}"></i> {{nc.name}}
|
||||
<i class="fa fa-remove pointer muted" ng-click="ctrl.removeNotification($index)" ng-if="nc.isDefault === false"></i>
|
||||
</span>
|
||||
<metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form gf-form--v-stretch">
|
||||
<span class="gf-form-label width-8">Message</span>
|
||||
<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message" placeholder="Notification message details..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2">
|
||||
<button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i> Clear history</button>
|
||||
<h5 class="section-heading" style="whitespace: nowrap">
|
||||
State history <span class="muted small">(last 50 state changes)</span>
|
||||
</h5>
|
||||
<div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2">
|
||||
<button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i> Clear history</button>
|
||||
<h5 class="section-heading" style="whitespace: nowrap">
|
||||
State history <span class="muted small">(last 50 state changes)</span>
|
||||
</h5>
|
||||
|
||||
<div ng-show="ctrl.alertHistory.length === 0">
|
||||
<br>
|
||||
<i>No state changes recorded</i>
|
||||
</div>
|
||||
<div ng-show="ctrl.alertHistory.length === 0">
|
||||
<br>
|
||||
<i>No state changes recorded</i>
|
||||
</div>
|
||||
|
||||
<ol class="alert-rule-list" >
|
||||
<li class="alert-rule-item" ng-repeat="al in ctrl.alertHistory">
|
||||
<div class="alert-rule-item__icon {{al.stateModel.stateClass}}">
|
||||
<i class="{{al.stateModel.iconClass}}"></i>
|
||||
</div>
|
||||
<div class="alert-rule-item__body">
|
||||
<div class="alert-rule-item__header">
|
||||
<div class="alert-rule-item__text">
|
||||
<span class="{{al.stateModel.stateClass}}">{{al.stateModel.text}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="alert-list-info">{{al.info}}</span>
|
||||
</div>
|
||||
<div class="alert-rule-item__time">
|
||||
<span>{{al.time}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="alert-rule-list" >
|
||||
<li class="alert-rule-item" ng-repeat="al in ctrl.alertHistory">
|
||||
<div class="alert-rule-item__icon {{al.stateModel.stateClass}}">
|
||||
<i class="{{al.stateModel.iconClass}}"></i>
|
||||
</div>
|
||||
<div class="alert-rule-item__body">
|
||||
<div class="alert-rule-item__header">
|
||||
<div class="alert-rule-item__text">
|
||||
<span class="{{al.stateModel.stateClass}}">{{al.stateModel.text}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="alert-list-info">{{al.info}}</span>
|
||||
</div>
|
||||
<div class="alert-rule-item__time">
|
||||
<span>{{al.time}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group" ng-if="!ctrl.alert">
|
||||
<div class="gf-form-button-row">
|
||||
<button class="btn btn-inverse" ng-click="ctrl.enable()">
|
||||
<i class="icon-gf icon-gf-alert"></i>
|
||||
Create Alert
|
||||
</button>
|
||||
</div>
|
||||
<div class="gf-form-group p-t-4 p-b-4" ng-if="!ctrl.alert">
|
||||
<div class="empty-list-cta">
|
||||
<div class="empty-list-cta__title">Panel has no alert rule defined</div>
|
||||
<button class="empty-list-cta__button btn btn-xlarge btn-success" ng-click="ctrl.enable()">
|
||||
<i class="icon-gf icon-gf-alert"></i>
|
||||
Create Alert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -143,7 +143,7 @@ export class DashboardMigrator {
|
||||
panelUpgrades.push(panel => {
|
||||
_.each(panel.targets, target => {
|
||||
if (!target.refId) {
|
||||
target.refId = this.dashboard.getNextQueryLetter(panel);
|
||||
target.refId = panel.getNextQueryLetter && panel.getNextQueryLetter();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -806,16 +806,6 @@ export class DashboardModel {
|
||||
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() {
|
||||
return this.getTimezone() === 'utc';
|
||||
}
|
||||
|
@ -1,13 +1,12 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import config from 'app/core/config';
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { DashboardModel } from '../dashboard_model';
|
||||
import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
|
||||
import store from 'app/core/store';
|
||||
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import { updateLocation } from 'app/core/actions';
|
||||
import { store as reduxStore } from 'app/store/store';
|
||||
|
||||
export interface AddPanelPanelProps {
|
||||
panel: PanelModel;
|
||||
@ -15,64 +14,25 @@ export interface AddPanelPanelProps {
|
||||
}
|
||||
|
||||
export interface AddPanelPanelState {
|
||||
filter: string;
|
||||
panelPlugins: any[];
|
||||
copiedPanelPlugins: any[];
|
||||
tab: string;
|
||||
}
|
||||
|
||||
export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelPanelState> {
|
||||
private scrollbar: ScrollBar;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this);
|
||||
this.renderPanelItem = this.renderPanelItem.bind(this);
|
||||
this.panelSizeChanged = this.panelSizeChanged.bind(this);
|
||||
|
||||
this.state = {
|
||||
panelPlugins: this.getPanelPlugins(''),
|
||||
copiedPanelPlugins: this.getCopiedPanelPlugins(''),
|
||||
filter: '',
|
||||
tab: 'Add',
|
||||
copiedPanelPlugins: this.getCopiedPanelPlugins(),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.panel.events.on('panel-size-changed', this.panelSizeChanged);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.panel.events.off('panel-size-changed', this.panelSizeChanged);
|
||||
}
|
||||
|
||||
panelSizeChanged() {
|
||||
setTimeout(() => {
|
||||
this.scrollbar.update();
|
||||
});
|
||||
}
|
||||
|
||||
getPanelPlugins(filter) {
|
||||
let panels = _.chain(config.panels)
|
||||
.filter({ hideFromList: false })
|
||||
.map(item => item)
|
||||
.value();
|
||||
|
||||
// add special row type
|
||||
panels.push({ id: 'row', name: 'Row', sort: 8, info: { logos: { small: 'public/img/icn-row.svg' } } });
|
||||
|
||||
panels = this.filterPanels(panels, filter);
|
||||
|
||||
// add sort by sort property
|
||||
return _.sortBy(panels, 'sort');
|
||||
}
|
||||
|
||||
getCopiedPanelPlugins(filter) {
|
||||
getCopiedPanelPlugins() {
|
||||
const panels = _.chain(config.panels)
|
||||
.filter({ hideFromList: false })
|
||||
.map(item => item)
|
||||
.value();
|
||||
let copiedPanels = [];
|
||||
const copiedPanels = [];
|
||||
|
||||
const copiedPanelJson = store.get(LS_PANEL_COPY_KEY);
|
||||
if (copiedPanelJson) {
|
||||
@ -86,13 +46,52 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
|
||||
copiedPanels.push(pluginCopy);
|
||||
}
|
||||
}
|
||||
|
||||
copiedPanels = this.filterPanels(copiedPanels, filter);
|
||||
|
||||
return _.sortBy(copiedPanels, 'sort');
|
||||
}
|
||||
|
||||
onAddPanel = panelPluginInfo => {
|
||||
handleCloseAddPanel(evt) {
|
||||
evt.preventDefault();
|
||||
this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
|
||||
}
|
||||
|
||||
copyButton(panel) {
|
||||
return (
|
||||
<button className="btn-inverse btn" onClick={() => this.onPasteCopiedPanel(panel)} title={panel.name}>
|
||||
Paste copied Panel
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
moveToEdit(panel) {
|
||||
reduxStore.dispatch(
|
||||
updateLocation({
|
||||
query: {
|
||||
panelId: panel.id,
|
||||
edit: true,
|
||||
fullscreen: true,
|
||||
},
|
||||
partial: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onCreateNewPanel = () => {
|
||||
const dashboard = this.props.dashboard;
|
||||
const { gridPos } = this.props.panel;
|
||||
|
||||
const newPanel: any = {
|
||||
type: 'graph',
|
||||
title: 'Panel Title',
|
||||
gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h },
|
||||
};
|
||||
|
||||
dashboard.addPanel(newPanel);
|
||||
dashboard.removePanel(this.props.panel);
|
||||
|
||||
this.moveToEdit(newPanel);
|
||||
};
|
||||
|
||||
onPasteCopiedPanel = panelPluginInfo => {
|
||||
const dashboard = this.props.dashboard;
|
||||
const { gridPos } = this.props.panel;
|
||||
|
||||
@ -102,16 +101,9 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
|
||||
gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h },
|
||||
};
|
||||
|
||||
if (panelPluginInfo.id === 'row') {
|
||||
newPanel.title = 'Row title';
|
||||
newPanel.gridPos = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
// apply panel template / defaults
|
||||
if (panelPluginInfo.defaults) {
|
||||
_.defaults(newPanel, panelPluginInfo.defaults);
|
||||
newPanel.gridPos.w = panelPluginInfo.defaults.gridPos.w;
|
||||
newPanel.gridPos.h = panelPluginInfo.defaults.gridPos.h;
|
||||
newPanel.title = panelPluginInfo.defaults.title;
|
||||
store.delete(LS_PANEL_COPY_KEY);
|
||||
}
|
||||
@ -120,133 +112,44 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
|
||||
dashboard.removePanel(this.props.panel);
|
||||
};
|
||||
|
||||
handleCloseAddPanel(evt) {
|
||||
evt.preventDefault();
|
||||
this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
|
||||
}
|
||||
onCreateNewRow = () => {
|
||||
const dashboard = this.props.dashboard;
|
||||
|
||||
renderText(text: string) {
|
||||
const searchWords = this.state.filter.split('');
|
||||
return <Highlighter highlightClassName="highlight-search-match" textToHighlight={text} searchWords={searchWords} />;
|
||||
}
|
||||
const newRow: any = {
|
||||
type: 'row',
|
||||
title: 'Row title',
|
||||
gridPos: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
renderPanelItem(panel, index) {
|
||||
return (
|
||||
<div key={index} className="add-panel__item" onClick={() => this.onAddPanel(panel)} title={panel.name}>
|
||||
<img className="add-panel__item-img" src={panel.info.logos.small} />
|
||||
<div className="add-panel__item-name">{this.renderText(panel.name)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
noCopiedPanelPlugins() {
|
||||
return <div className="add-panel__no-panels">No copied panels yet.</div>;
|
||||
}
|
||||
|
||||
filterChange(evt) {
|
||||
this.setState({
|
||||
filter: evt.target.value,
|
||||
panelPlugins: this.getPanelPlugins(evt.target.value),
|
||||
copiedPanelPlugins: this.getCopiedPanelPlugins(evt.target.value),
|
||||
});
|
||||
}
|
||||
|
||||
filterKeyPress(evt) {
|
||||
if (evt.key === 'Enter') {
|
||||
const panel = _.head(this.state.panelPlugins);
|
||||
if (panel) {
|
||||
this.onAddPanel(panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filterPanels(panels, filter) {
|
||||
const regex = new RegExp(filter, 'i');
|
||||
return panels.filter(panel => {
|
||||
return regex.test(panel.name);
|
||||
});
|
||||
}
|
||||
|
||||
openCopy() {
|
||||
this.setState({
|
||||
tab: 'Copy',
|
||||
filter: '',
|
||||
panelPlugins: this.getPanelPlugins(''),
|
||||
copiedPanelPlugins: this.getCopiedPanelPlugins(''),
|
||||
});
|
||||
}
|
||||
|
||||
openAdd() {
|
||||
this.setState({
|
||||
tab: 'Add',
|
||||
filter: '',
|
||||
panelPlugins: this.getPanelPlugins(''),
|
||||
copiedPanelPlugins: this.getCopiedPanelPlugins(''),
|
||||
});
|
||||
}
|
||||
dashboard.addPanel(newRow);
|
||||
dashboard.removePanel(this.props.panel);
|
||||
};
|
||||
|
||||
render() {
|
||||
const addClass = classNames({
|
||||
'active active--panel': this.state.tab === 'Add',
|
||||
'': this.state.tab === 'Copy',
|
||||
});
|
||||
let addCopyButton;
|
||||
|
||||
const copyClass = classNames({
|
||||
'': this.state.tab === 'Add',
|
||||
'active active--panel': this.state.tab === 'Copy',
|
||||
});
|
||||
|
||||
let panelTab;
|
||||
|
||||
if (this.state.tab === 'Add') {
|
||||
panelTab = this.state.panelPlugins.map(this.renderPanelItem);
|
||||
} else if (this.state.tab === 'Copy') {
|
||||
if (this.state.copiedPanelPlugins.length > 0) {
|
||||
panelTab = this.state.copiedPanelPlugins.map(this.renderPanelItem);
|
||||
} else {
|
||||
panelTab = this.noCopiedPanelPlugins();
|
||||
}
|
||||
if (this.state.copiedPanelPlugins.length === 1) {
|
||||
addCopyButton = this.copyButton(this.state.copiedPanelPlugins[0]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel-container add-panel-container">
|
||||
<div className="add-panel">
|
||||
<div className="add-panel__header">
|
||||
<div className="add-panel__header grid-drag-handle">
|
||||
<i className="gicon gicon-add-panel" />
|
||||
<span className="add-panel__title">New Panel</span>
|
||||
<ul className="gf-tabs">
|
||||
<li className="gf-tabs-item">
|
||||
<div className={'gf-tabs-link pointer ' + addClass} onClick={this.openAdd.bind(this)}>
|
||||
Add
|
||||
</div>
|
||||
</li>
|
||||
<li className="gf-tabs-item">
|
||||
<div className={'gf-tabs-link pointer ' + copyClass} onClick={this.openCopy.bind(this)}>
|
||||
Paste
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button className="add-panel__close" onClick={this.handleCloseAddPanel}>
|
||||
<i className="fa fa-close" />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollBar ref={element => (this.scrollbar = element)} className="add-panel__items">
|
||||
<div className="add-panel__searchbar">
|
||||
<label className="gf-form gf-form--grow gf-form--has-input-icon">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
className="gf-form-input gf-form--grow"
|
||||
placeholder="Panel Search Filter"
|
||||
value={this.state.filter}
|
||||
onChange={this.filterChange.bind(this)}
|
||||
onKeyPress={this.filterKeyPress.bind(this)}
|
||||
/>
|
||||
<i className="gf-form-input-icon fa fa-search" />
|
||||
</label>
|
||||
</div>
|
||||
{panelTab}
|
||||
</ScrollBar>
|
||||
<div className="add-panel-btn-container">
|
||||
<button className="btn-success btn btn-large" onClick={this.onCreateNewPanel}>
|
||||
Edit Panel
|
||||
</button>
|
||||
{addCopyButton}
|
||||
<button className="btn-inverse btn" onClick={this.onCreateNewRow}>
|
||||
Add Row
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
72
public/app/features/dashboard/dashgrid/AlertTab.tsx
Normal file
72
public/app/features/dashboard/dashgrid/AlertTab.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
|
||||
import { EditorTabBody } from './EditorTabBody';
|
||||
import 'app/features/alerting/AlertTabCtrl';
|
||||
|
||||
interface Props {
|
||||
angularPanel?: AngularComponent;
|
||||
}
|
||||
|
||||
export class AlertTab extends PureComponent<Props> {
|
||||
element: any;
|
||||
component: AngularComponent;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.shouldLoadAlertTab()) {
|
||||
this.loadAlertTab();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.shouldLoadAlertTab()) {
|
||||
this.loadAlertTab();
|
||||
}
|
||||
}
|
||||
|
||||
shouldLoadAlertTab() {
|
||||
return this.props.angularPanel && this.element;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.component) {
|
||||
this.component.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
loadAlertTab() {
|
||||
const { angularPanel } = this.props;
|
||||
|
||||
const scope = angularPanel.getScope();
|
||||
|
||||
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
|
||||
if (!scope.$$childHead) {
|
||||
setTimeout(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const panelCtrl = scope.$$childHead.ctrl;
|
||||
const loader = getAngularLoader();
|
||||
const template = '<alert-tab />';
|
||||
|
||||
const scopeProps = {
|
||||
ctrl: panelCtrl,
|
||||
};
|
||||
|
||||
this.component = loader.load(this.element, scopeProps, template);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditorTabBody heading="Alert" toolbarItems={[]}>
|
||||
<div ref={element => (this.element = element)} />
|
||||
</EditorTabBody>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import ReactGridLayout from 'react-grid-layout';
|
||||
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
|
||||
import { DashboardPanel } from './DashboardPanel';
|
||||
@ -8,6 +9,7 @@ import classNames from 'classnames';
|
||||
import sizeMe from 'react-sizeme';
|
||||
|
||||
let lastGridWidth = 1200;
|
||||
let ignoreNextWidthChange = false;
|
||||
|
||||
function GridWrapper({
|
||||
size,
|
||||
@ -24,8 +26,12 @@ function GridWrapper({
|
||||
isFullscreen,
|
||||
}) {
|
||||
const width = size.width > 0 ? size.width : lastGridWidth;
|
||||
|
||||
// logic to ignore width changes (optimization)
|
||||
if (width !== lastGridWidth) {
|
||||
if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
|
||||
if (ignoreNextWidthChange) {
|
||||
ignoreNextWidthChange = false;
|
||||
} else if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
|
||||
onWidthChange();
|
||||
lastGridWidth = width;
|
||||
}
|
||||
@ -39,7 +45,7 @@ function GridWrapper({
|
||||
isResizable={isResizable}
|
||||
measureBeforeMount={false}
|
||||
containerPadding={[0, 0]}
|
||||
useCSSTransforms={true}
|
||||
useCSSTransforms={false}
|
||||
margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
|
||||
cols={GRID_COLUMN_COUNT}
|
||||
rowHeight={GRID_CELL_HEIGHT}
|
||||
@ -61,7 +67,7 @@ export interface DashboardGridProps {
|
||||
dashboard: DashboardModel;
|
||||
}
|
||||
|
||||
export class DashboardGrid extends React.Component<DashboardGridProps, any> {
|
||||
export class DashboardGrid extends React.Component<DashboardGridProps> {
|
||||
gridToPanelMap: any;
|
||||
panelMap: { [id: string]: PanelModel };
|
||||
|
||||
@ -73,8 +79,6 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
|
||||
this.onDragStop = this.onDragStop.bind(this);
|
||||
this.onWidthChange = this.onWidthChange.bind(this);
|
||||
|
||||
this.state = { animated: false };
|
||||
|
||||
// subscribe to dashboard events
|
||||
const dashboard = this.props.dashboard;
|
||||
dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
|
||||
@ -138,7 +142,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
|
||||
}
|
||||
|
||||
onViewModeChanged(payload) {
|
||||
this.setState({ animated: !payload.fullscreen });
|
||||
ignoreNextWidthChange = true;
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
updateGridPos(item, layout) {
|
||||
@ -162,17 +167,11 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
|
||||
this.updateGridPos(newItem, layout);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
this.setState({ animated: true });
|
||||
});
|
||||
}
|
||||
|
||||
renderPanels() {
|
||||
const panelElements = [];
|
||||
|
||||
for (const panel of this.props.dashboard.panels) {
|
||||
const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen });
|
||||
const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.fullscreen });
|
||||
panelElements.push(
|
||||
<div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id}`}>
|
||||
<DashboardPanel
|
||||
@ -191,7 +190,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
|
||||
render() {
|
||||
return (
|
||||
<SizedReactLayoutGrid
|
||||
className={classNames({ layout: true, animated: this.state.animated })}
|
||||
className={classNames({ layout: true })}
|
||||
layout={this.buildLayout()}
|
||||
isResizable={this.props.dashboard.meta.canEdit}
|
||||
isDraggable={this.props.dashboard.meta.canEdit}
|
||||
@ -207,3 +206,5 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default hot(module)(DashboardGrid);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { react2AngularDirective } from 'app/core/utils/react2angular';
|
||||
import { DashboardGrid } from './DashboardGrid';
|
||||
import DashboardGrid from './DashboardGrid';
|
||||
|
||||
react2AngularDirective('dashboardGrid', DashboardGrid, [['dashboard', { watchDepth: 'reference' }]]);
|
||||
|
@ -1,15 +1,20 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import config from 'app/core/config';
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { DashboardModel } from '../dashboard_model';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
|
||||
import { DashboardRow } from './DashboardRow';
|
||||
import { AddPanelPanel } from './AddPanelPanel';
|
||||
import { importPluginModule } from 'app/features/plugins/plugin_loader';
|
||||
import { PluginExports, PanelPlugin } from 'app/types/plugins';
|
||||
|
||||
import { AddPanelPanel } from './AddPanelPanel';
|
||||
import { getPanelPluginNotFound } from './PanelPluginNotFound';
|
||||
import { DashboardRow } from './DashboardRow';
|
||||
import { PanelChrome } from './PanelChrome';
|
||||
import { PanelEditor } from './PanelEditor';
|
||||
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { DashboardModel } from '../dashboard_model';
|
||||
import { PanelPlugin } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
panel: PanelModel;
|
||||
dashboard: DashboardModel;
|
||||
@ -18,28 +23,28 @@ export interface Props {
|
||||
}
|
||||
|
||||
export interface State {
|
||||
pluginExports: PluginExports;
|
||||
plugin: PanelPlugin;
|
||||
angularPanel: AngularComponent;
|
||||
}
|
||||
|
||||
export class DashboardPanel extends PureComponent<Props, State> {
|
||||
element: any;
|
||||
angularPanel: AngularComponent;
|
||||
pluginInfo: any;
|
||||
element: HTMLElement;
|
||||
specialPanels = {};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
pluginExports: null,
|
||||
plugin: null,
|
||||
angularPanel: null,
|
||||
};
|
||||
|
||||
this.specialPanels['row'] = this.renderRow.bind(this);
|
||||
this.specialPanels['add-panel'] = this.renderAddPanel.bind(this);
|
||||
}
|
||||
|
||||
isSpecial() {
|
||||
return this.specialPanels[this.props.panel.type];
|
||||
isSpecial(pluginId: string) {
|
||||
return this.specialPanels[pluginId];
|
||||
}
|
||||
|
||||
renderRow() {
|
||||
@ -51,60 +56,60 @@ export class DashboardPanel extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
onPluginTypeChanged = (plugin: PanelPlugin) => {
|
||||
this.props.panel.changeType(plugin.id);
|
||||
this.loadPlugin();
|
||||
this.loadPlugin(plugin.id);
|
||||
};
|
||||
|
||||
onAngularPluginTypeChanged = () => {
|
||||
this.loadPlugin();
|
||||
};
|
||||
|
||||
loadPlugin() {
|
||||
if (this.isSpecial()) {
|
||||
async loadPlugin(pluginId: string) {
|
||||
if (this.isSpecial(pluginId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// handle plugin loading & changing of plugin type
|
||||
if (!this.pluginInfo || this.pluginInfo.id !== this.props.panel.type) {
|
||||
this.pluginInfo = config.panels[this.props.panel.type];
|
||||
const { panel } = this.props;
|
||||
|
||||
if (this.pluginInfo.exports) {
|
||||
this.cleanUpAngularPanel();
|
||||
this.setState({ pluginExports: this.pluginInfo.exports });
|
||||
// handle plugin loading & changing of plugin type
|
||||
if (!this.state.plugin || this.state.plugin.id !== pluginId) {
|
||||
const plugin = config.panels[pluginId] || getPanelPluginNotFound(pluginId);
|
||||
|
||||
// remember if this is from an angular panel
|
||||
const fromAngularPanel = this.state.angularPanel != null;
|
||||
|
||||
// unmount angular panel
|
||||
this.cleanUpAngularPanel();
|
||||
|
||||
if (panel.type !== pluginId) {
|
||||
this.props.panel.changeType(pluginId, fromAngularPanel);
|
||||
}
|
||||
|
||||
if (plugin.exports) {
|
||||
this.setState({ plugin: plugin, angularPanel: null });
|
||||
} else {
|
||||
importPluginModule(this.pluginInfo.module).then(pluginExports => {
|
||||
this.cleanUpAngularPanel();
|
||||
// cache plugin exports (saves a promise async cycle next time)
|
||||
this.pluginInfo.exports = pluginExports;
|
||||
// update panel state
|
||||
this.setState({ pluginExports: pluginExports });
|
||||
});
|
||||
plugin.exports = await importPluginModule(plugin.module);
|
||||
this.setState({ plugin: plugin, angularPanel: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadPlugin();
|
||||
this.loadPlugin(this.props.panel.type);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.loadPlugin();
|
||||
|
||||
// handle angular plugin loading
|
||||
if (!this.element || this.angularPanel) {
|
||||
if (!this.element || this.state.angularPanel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = getAngularLoader();
|
||||
const template = '<plugin-component type="panel" class="panel-height-helper"></plugin-component>';
|
||||
const scopeProps = { panel: this.props.panel, dashboard: this.props.dashboard };
|
||||
this.angularPanel = loader.load(this.element, scopeProps, template);
|
||||
const angularPanel = loader.load(this.element, scopeProps, template);
|
||||
|
||||
this.setState({ angularPanel });
|
||||
}
|
||||
|
||||
cleanUpAngularPanel() {
|
||||
if (this.angularPanel) {
|
||||
this.angularPanel.destroy();
|
||||
this.angularPanel = null;
|
||||
if (this.state.angularPanel) {
|
||||
this.state.angularPanel.destroy();
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,50 +117,61 @@ export class DashboardPanel extends PureComponent<Props, State> {
|
||||
this.cleanUpAngularPanel();
|
||||
}
|
||||
|
||||
onMouseEnter = () => {
|
||||
this.props.dashboard.setPanelFocus(this.props.panel.id);
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this.props.dashboard.setPanelFocus(0);
|
||||
};
|
||||
|
||||
renderReactPanel() {
|
||||
const { pluginExports } = this.state;
|
||||
const containerClass = this.props.isEditing ? 'panel-editor-container' : 'panel-height-helper';
|
||||
const panelWrapperClass = this.props.isEditing ? 'panel-editor-container__panel' : 'panel-height-helper';
|
||||
// this might look strange with these classes that change when edit, but
|
||||
// I want to try to keep markup (parents) for panel the same in edit mode to avoide unmount / new mount of panel
|
||||
const { dashboard, panel } = this.props;
|
||||
const { plugin } = this.state;
|
||||
|
||||
return <PanelChrome plugin={plugin} panel={panel} dashboard={dashboard} />;
|
||||
}
|
||||
|
||||
renderAngularPanel() {
|
||||
return <div ref={element => (this.element = element)} className="panel-height-helper" />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { panel, dashboard, isFullscreen, isEditing } = this.props;
|
||||
const { plugin, angularPanel } = this.state;
|
||||
|
||||
if (this.isSpecial(panel.type)) {
|
||||
return this.specialPanels[panel.type]();
|
||||
}
|
||||
|
||||
// if we have not loaded plugin exports yet, wait
|
||||
if (!plugin || !plugin.exports) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerClass = classNames({ 'panel-editor-container': isEditing, 'panel-height-helper': !isEditing });
|
||||
const panelWrapperClass = classNames({
|
||||
'panel-wrapper': true,
|
||||
'panel-wrapper--edit': isEditing,
|
||||
'panel-wrapper--view': isFullscreen && !isEditing,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div className={panelWrapperClass}>
|
||||
<PanelChrome
|
||||
component={pluginExports.PanelComponent}
|
||||
panel={this.props.panel}
|
||||
dashboard={this.props.dashboard}
|
||||
/>
|
||||
<div className={panelWrapperClass} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
|
||||
{plugin.exports.Panel && this.renderReactPanel()}
|
||||
{plugin.exports.PanelCtrl && this.renderAngularPanel()}
|
||||
</div>
|
||||
{this.props.panel.isEditing && (
|
||||
<div className="panel-editor-container__editor">
|
||||
<PanelEditor
|
||||
panel={this.props.panel}
|
||||
panelType={this.props.panel.type}
|
||||
dashboard={this.props.dashboard}
|
||||
onTypeChanged={this.onPluginTypeChanged}
|
||||
pluginExports={pluginExports}
|
||||
/>
|
||||
</div>
|
||||
{panel.isEditing && (
|
||||
<PanelEditor
|
||||
panel={panel}
|
||||
plugin={plugin}
|
||||
dashboard={dashboard}
|
||||
angularPanel={angularPanel}
|
||||
onTypeChanged={this.onPluginTypeChanged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.isSpecial()) {
|
||||
return this.specialPanels[this.props.panel.type]();
|
||||
}
|
||||
|
||||
if (!this.state.pluginExports) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.state.pluginExports.PanelComponent) {
|
||||
return this.renderReactPanel();
|
||||
}
|
||||
|
||||
// legacy angular rendering
|
||||
return <div ref={element => (this.element = element)} className="panel-height-helper" />;
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,10 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
// Services
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { getDatasourceSrv, DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
|
||||
// Utils
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
|
||||
// Types
|
||||
import { TimeRange, LoadingState, DataQueryOptions, DataQueryResponse, TimeSeries } from 'app/types';
|
||||
@ -19,7 +22,10 @@ export interface Props {
|
||||
dashboardId?: number;
|
||||
isVisible?: boolean;
|
||||
timeRange?: TimeRange;
|
||||
widthPixels: number;
|
||||
refreshCounter: number;
|
||||
minInterval?: string;
|
||||
maxDataPoints?: number;
|
||||
children: (r: RenderProps) => JSX.Element;
|
||||
}
|
||||
|
||||
@ -36,6 +42,9 @@ export class DataPanel extends Component<Props, State> {
|
||||
dashboardId: 1,
|
||||
};
|
||||
|
||||
dataSourceSrv: DatasourceSrv = getDatasourceSrv();
|
||||
isUnmounted = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
@ -49,7 +58,11 @@ export class DataPanel extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
console.log('DataPanel mount');
|
||||
this.issueQueries();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isUnmounted = true;
|
||||
}
|
||||
|
||||
async componentDidUpdate(prevProps: Props) {
|
||||
@ -61,11 +74,11 @@ export class DataPanel extends Component<Props, State> {
|
||||
}
|
||||
|
||||
hasPropsChanged(prevProps: Props) {
|
||||
return this.props.refreshCounter !== prevProps.refreshCounter || this.props.isVisible !== prevProps.isVisible;
|
||||
return this.props.refreshCounter !== prevProps.refreshCounter;
|
||||
}
|
||||
|
||||
issueQueries = async () => {
|
||||
const { isVisible, queries, datasource, panelId, dashboardId, timeRange } = this.props;
|
||||
private issueQueries = async () => {
|
||||
const { isVisible, queries, datasource, panelId, dashboardId, timeRange, widthPixels, maxDataPoints } = this.props;
|
||||
|
||||
if (!isVisible) {
|
||||
return;
|
||||
@ -79,8 +92,11 @@ export class DataPanel extends Component<Props, State> {
|
||||
this.setState({ loading: LoadingState.Loading });
|
||||
|
||||
try {
|
||||
const dataSourceSrv = getDatasourceSrv();
|
||||
const ds = await dataSourceSrv.get(datasource);
|
||||
const ds = await this.dataSourceSrv.get(datasource);
|
||||
|
||||
// TODO interpolate variables
|
||||
const minInterval = this.props.minInterval || ds.interval;
|
||||
const intervalRes = kbn.calculateInterval(timeRange, widthPixels, minInterval);
|
||||
|
||||
const queryOptions: DataQueryOptions = {
|
||||
timezone: 'browser',
|
||||
@ -88,10 +104,10 @@ export class DataPanel extends Component<Props, State> {
|
||||
dashboardId: dashboardId,
|
||||
range: timeRange,
|
||||
rangeRaw: timeRange.raw,
|
||||
interval: '1s',
|
||||
intervalMs: 60000,
|
||||
interval: intervalRes.interval,
|
||||
intervalMs: intervalRes.intervalMs,
|
||||
targets: queries,
|
||||
maxDataPoints: 500,
|
||||
maxDataPoints: maxDataPoints || widthPixels,
|
||||
scopedVars: {},
|
||||
cacheTimeout: null,
|
||||
};
|
||||
@ -100,6 +116,10 @@ export class DataPanel extends Component<Props, State> {
|
||||
const resp = await ds.query(queryOptions);
|
||||
console.log('Issuing DataPanel query Resp', resp);
|
||||
|
||||
if (this.isUnmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: LoadingState.Done,
|
||||
response: resp,
|
||||
@ -112,21 +132,26 @@ export class DataPanel extends Component<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { queries } = this.props;
|
||||
const { response, loading, isFirstLoad } = this.state;
|
||||
console.log('data panel render');
|
||||
|
||||
const timeSeries = response.data;
|
||||
|
||||
if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
|
||||
if (isFirstLoad && loading === LoadingState.Loading) {
|
||||
return this.renderLoadingSpinner();
|
||||
}
|
||||
|
||||
if (!queries.length) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<p>Loading</p>
|
||||
<div className="panel-empty">
|
||||
<p>Add a query to get some data!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.loadingSpinner}
|
||||
{this.renderLoadingSpinner()}
|
||||
{this.props.children({
|
||||
timeSeries,
|
||||
loading,
|
||||
@ -135,12 +160,12 @@ export class DataPanel extends Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
private get loadingSpinner(): JSX.Element {
|
||||
private renderLoadingSpinner(): JSX.Element {
|
||||
const { loading } = this.state;
|
||||
|
||||
if (loading === LoadingState.Loading) {
|
||||
return (
|
||||
<div className="panel__loading">
|
||||
<div className="panel-loading">
|
||||
<i className="fa fa-spinner fa-spin" />
|
||||
</div>
|
||||
);
|
||||
|
31
public/app/features/dashboard/dashgrid/DataSourceOption.tsx
Normal file
31
public/app/features/dashboard/dashgrid/DataSourceOption.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React, { SFC } from 'react';
|
||||
import Tooltip from 'app/core/components/Tooltip/Tooltip';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
onChange?: (evt: any) => void;
|
||||
tooltipInfo?: any;
|
||||
}
|
||||
|
||||
export const DataSourceOptions: SFC<Props> = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
|
||||
const dsOption = (
|
||||
<div className="gf-form gf-form--flex-end">
|
||||
<label className="gf-form-label">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input width-6"
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
spellCheck={false}
|
||||
onBlur={evt => onChange(evt.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return tooltipInfo ? <Tooltip content={tooltipInfo}>{dsOption}</Tooltip> : dsOption;
|
||||
};
|
||||
|
||||
export default DataSourceOptions;
|
133
public/app/features/dashboard/dashgrid/EditorTabBody.tsx
Normal file
133
public/app/features/dashboard/dashgrid/EditorTabBody.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Components
|
||||
import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
|
||||
import { FadeIn } from 'app/core/components/Animations/FadeIn';
|
||||
import { PanelOptionSection } from './PanelOptionSection';
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
heading: string;
|
||||
renderToolbar?: () => JSX.Element;
|
||||
toolbarItems?: EditorToolBarView[];
|
||||
}
|
||||
|
||||
export interface EditorToolBarView {
|
||||
title?: string;
|
||||
heading?: string;
|
||||
imgSrc?: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
render: (closeFunction?: any) => JSX.Element | JSX.Element[];
|
||||
}
|
||||
|
||||
interface State {
|
||||
openView?: EditorToolBarView;
|
||||
isOpen: boolean;
|
||||
fadeIn: boolean;
|
||||
}
|
||||
|
||||
export class EditorTabBody extends PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
toolbarItems: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
openView: null,
|
||||
fadeIn: false,
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({ fadeIn: true });
|
||||
}
|
||||
|
||||
onToggleToolBarView = (item: EditorToolBarView) => {
|
||||
this.setState({
|
||||
openView: item,
|
||||
isOpen: !this.state.isOpen,
|
||||
});
|
||||
};
|
||||
|
||||
onCloseOpenView = () => {
|
||||
this.setState({ isOpen: false });
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (state.openView) {
|
||||
const activeToolbarItem = props.toolbarItems.find(
|
||||
item => item.title === state.openView.title && item.icon === state.openView.icon
|
||||
);
|
||||
if (activeToolbarItem) {
|
||||
return {
|
||||
...state,
|
||||
openView: activeToolbarItem,
|
||||
};
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
renderButton(view: EditorToolBarView) {
|
||||
const onClick = () => {
|
||||
if (view.onClick) {
|
||||
view.onClick();
|
||||
}
|
||||
this.onToggleToolBarView(view);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nav-buttons" key={view.title + view.icon}>
|
||||
<button className="btn navbar-button" onClick={onClick} disabled={view.disabled}>
|
||||
{view.icon && <i className={view.icon} />} {view.title}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderOpenView(view: EditorToolBarView) {
|
||||
return (
|
||||
<PanelOptionSection title={view.title || view.heading} onClose={this.onCloseOpenView}>
|
||||
{view.render()}
|
||||
</PanelOptionSection>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, renderToolbar, heading, toolbarItems } = this.props;
|
||||
const { openView, fadeIn, isOpen } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<div className="toolbar__heading">{heading}</div>
|
||||
{renderToolbar && renderToolbar()}
|
||||
{toolbarItems.length > 0 && (
|
||||
<>
|
||||
<div className="gf-form--grow" />
|
||||
{toolbarItems.map(item => this.renderButton(item))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="panel-editor__scroll">
|
||||
<CustomScrollbar autoHide={false}>
|
||||
<div className="panel-editor__content">
|
||||
<FadeIn in={isOpen} duration={200} unmountOnExit={true}>
|
||||
{openView && this.renderOpenView(openView)}
|
||||
</FadeIn>
|
||||
<FadeIn in={fadeIn} duration={50}>
|
||||
{children}
|
||||
</FadeIn>
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
52
public/app/features/dashboard/dashgrid/GeneralTab.tsx
Normal file
52
public/app/features/dashboard/dashgrid/GeneralTab.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
|
||||
import { EditorTabBody } from './EditorTabBody';
|
||||
|
||||
import { PanelModel } from '../panel_model';
|
||||
import './../../panel/GeneralTabCtrl';
|
||||
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
}
|
||||
|
||||
export class GeneralTab extends PureComponent<Props> {
|
||||
element: any;
|
||||
component: AngularComponent;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { panel } = this.props;
|
||||
|
||||
const loader = getAngularLoader();
|
||||
const template = '<panel-general-tab />';
|
||||
const scopeProps = {
|
||||
ctrl: {
|
||||
panel: panel,
|
||||
},
|
||||
};
|
||||
|
||||
this.component = loader.load(this.element, scopeProps, template);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.component) {
|
||||
this.component.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditorTabBody heading="Panel Options" toolbarItems={[]}>
|
||||
<div ref={element => (this.element = element)} />
|
||||
</EditorTabBody>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
import React, { KeyboardEvent, Component } from 'react';
|
||||
|
||||
interface State {
|
||||
selected: number;
|
||||
}
|
||||
|
||||
export interface KeyboardNavigationProps {
|
||||
onKeyDown: (evt: KeyboardEvent<EventTarget>, maxSelectedIndex: number, onEnterAction: () => void) => void;
|
||||
onMouseEnter: (select: number) => void;
|
||||
selected: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
render: (injectProps: any) => void;
|
||||
}
|
||||
|
||||
class KeyboardNavigation extends Component<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selected: 0,
|
||||
};
|
||||
}
|
||||
|
||||
goToNext = (maxSelectedIndex: number) => {
|
||||
const nextIndex = this.state.selected >= maxSelectedIndex ? 0 : this.state.selected + 1;
|
||||
this.setState({
|
||||
selected: nextIndex,
|
||||
});
|
||||
};
|
||||
|
||||
goToPrev = (maxSelectedIndex: number) => {
|
||||
const nextIndex = this.state.selected <= 0 ? maxSelectedIndex : this.state.selected - 1;
|
||||
this.setState({
|
||||
selected: nextIndex,
|
||||
});
|
||||
};
|
||||
|
||||
onKeyDown = (evt: KeyboardEvent, maxSelectedIndex: number, onEnterAction: any) => {
|
||||
if (evt.key === 'ArrowDown') {
|
||||
evt.preventDefault();
|
||||
this.goToNext(maxSelectedIndex);
|
||||
}
|
||||
if (evt.key === 'ArrowUp') {
|
||||
evt.preventDefault();
|
||||
this.goToPrev(maxSelectedIndex);
|
||||
}
|
||||
if (evt.key === 'Enter' && onEnterAction) {
|
||||
onEnterAction();
|
||||
}
|
||||
};
|
||||
|
||||
onMouseEnter = (mouseEnterIndex: number) => {
|
||||
this.setState({
|
||||
selected: mouseEnterIndex,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const injectProps = {
|
||||
onKeyDown: this.onKeyDown,
|
||||
onMouseEnter: this.onMouseEnter,
|
||||
selected: this.state.selected,
|
||||
};
|
||||
|
||||
return <>{this.props.render({ ...injectProps })}</>;
|
||||
}
|
||||
}
|
||||
|
||||
export default KeyboardNavigation;
|
@ -1,31 +1,39 @@
|
||||
// Libraries
|
||||
import React, { ComponentClass, PureComponent } from 'react';
|
||||
import React, { PureComponent } from 'react';
|
||||
import { AutoSizer } from 'react-virtualized';
|
||||
|
||||
// Services
|
||||
import { getTimeSrv } from '../time_srv';
|
||||
import { getTimeSrv, TimeSrv } from '../time_srv';
|
||||
|
||||
// Components
|
||||
import { PanelHeader } from './PanelHeader/PanelHeader';
|
||||
import { DataPanel } from './DataPanel';
|
||||
|
||||
// Utils
|
||||
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
|
||||
import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
|
||||
|
||||
// Types
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { DashboardModel } from '../dashboard_model';
|
||||
import { TimeRange, PanelProps } from 'app/types';
|
||||
import { PanelPlugin, TimeRange } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
panel: PanelModel;
|
||||
dashboard: DashboardModel;
|
||||
component: ComponentClass<PanelProps>;
|
||||
plugin: PanelPlugin;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
refreshCounter: number;
|
||||
renderCounter: number;
|
||||
timeInfo?: string;
|
||||
timeRange?: TimeRange;
|
||||
}
|
||||
|
||||
export class PanelChrome extends PureComponent<Props, State> {
|
||||
timeSrv: TimeSrv = getTimeSrv();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@ -46,22 +54,25 @@ export class PanelChrome extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
onRefresh = () => {
|
||||
const timeSrv = getTimeSrv();
|
||||
const timeRange = timeSrv.timeRange();
|
||||
console.log('onRefresh');
|
||||
if (!this.isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
const { panel } = this.props;
|
||||
const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange());
|
||||
|
||||
this.setState({
|
||||
refreshCounter: this.state.refreshCounter + 1,
|
||||
timeRange: timeRange,
|
||||
}));
|
||||
timeRange: timeData.timeRange,
|
||||
timeInfo: timeData.timeInfo,
|
||||
});
|
||||
};
|
||||
|
||||
onRender = () => {
|
||||
console.log('onRender');
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
this.setState({
|
||||
renderCounter: this.state.renderCounter + 1,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
get isVisible() {
|
||||
@ -69,39 +80,60 @@ export class PanelChrome extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { panel, dashboard } = this.props;
|
||||
const { refreshCounter, timeRange, renderCounter } = this.state;
|
||||
const { panel, dashboard, plugin } = this.props;
|
||||
const { refreshCounter, timeRange, timeInfo, renderCounter } = this.state;
|
||||
|
||||
const { datasource, targets } = panel;
|
||||
const PanelComponent = this.props.component;
|
||||
const { datasource, targets, transparent } = panel;
|
||||
const PanelComponent = plugin.exports.Panel;
|
||||
const containerClassNames = `panel-container panel-container--absolute ${transparent ? 'panel-transparent' : ''}`;
|
||||
|
||||
console.log('panelChrome render');
|
||||
return (
|
||||
<div className="panel-container">
|
||||
<PanelHeader panel={panel} dashboard={dashboard} />
|
||||
<div className="panel-content">
|
||||
<DataPanel
|
||||
datasource={datasource}
|
||||
queries={targets}
|
||||
timeRange={timeRange}
|
||||
isVisible={this.isVisible}
|
||||
refreshCounter={refreshCounter}
|
||||
>
|
||||
{({ loading, timeSeries }) => {
|
||||
console.log('panelcrome inner render');
|
||||
return (
|
||||
<PanelComponent
|
||||
loading={loading}
|
||||
timeSeries={timeSeries}
|
||||
timeRange={timeRange}
|
||||
options={panel.getOptions()}
|
||||
renderCounter={renderCounter}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</DataPanel>
|
||||
</div>
|
||||
</div>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => {
|
||||
if (width === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClassNames}>
|
||||
<PanelHeader
|
||||
panel={panel}
|
||||
dashboard={dashboard}
|
||||
timeInfo={timeInfo}
|
||||
title={panel.title}
|
||||
description={panel.description}
|
||||
scopedVars={panel.scopedVars}
|
||||
links={panel.links}
|
||||
/>
|
||||
|
||||
<DataPanel
|
||||
datasource={datasource}
|
||||
queries={targets}
|
||||
timeRange={timeRange}
|
||||
isVisible={this.isVisible}
|
||||
widthPixels={width}
|
||||
refreshCounter={refreshCounter}
|
||||
>
|
||||
{({ loading, timeSeries }) => {
|
||||
return (
|
||||
<div className="panel-content">
|
||||
<PanelComponent
|
||||
loading={loading}
|
||||
timeSeries={timeSeries}
|
||||
timeRange={timeRange}
|
||||
options={panel.getOptions(plugin.exports.PanelDefaults)}
|
||||
width={width}
|
||||
height={height - PANEL_HEADER_HEIGHT}
|
||||
renderCounter={renderCounter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</DataPanel>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2,73 +2,37 @@ import React, { PureComponent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { QueriesTab } from './QueriesTab';
|
||||
import { VizTypePicker } from './VizTypePicker';
|
||||
import { VisualizationTab } from './VisualizationTab';
|
||||
import { GeneralTab } from './GeneralTab';
|
||||
import { AlertTab } from './AlertTab';
|
||||
|
||||
import { store } from 'app/store/configureStore';
|
||||
import config from 'app/core/config';
|
||||
import { store } from 'app/store/store';
|
||||
import { updateLocation } from 'app/core/actions';
|
||||
import { AngularComponent } from 'app/core/services/AngularLoader';
|
||||
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { DashboardModel } from '../dashboard_model';
|
||||
import { PanelPlugin, PluginExports } from 'app/types/plugins';
|
||||
import { PanelPlugin } from 'app/types/plugins';
|
||||
|
||||
import Tooltip from 'app/core/components/Tooltip/Tooltip';
|
||||
|
||||
interface PanelEditorProps {
|
||||
panel: PanelModel;
|
||||
dashboard: DashboardModel;
|
||||
panelType: string;
|
||||
pluginExports: PluginExports;
|
||||
plugin: PanelPlugin;
|
||||
angularPanel?: AngularComponent;
|
||||
onTypeChanged: (newType: PanelPlugin) => void;
|
||||
}
|
||||
|
||||
interface PanelEditorTab {
|
||||
id: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export class PanelEditor extends PureComponent<PanelEditorProps> {
|
||||
tabs: PanelEditorTab[];
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.tabs = [
|
||||
{ id: 'queries', text: 'Queries', icon: 'fa fa-database' },
|
||||
{ id: 'visualization', text: 'Visualization', icon: 'fa fa-line-chart' },
|
||||
];
|
||||
}
|
||||
|
||||
renderQueriesTab() {
|
||||
return <QueriesTab panel={this.props.panel} dashboard={this.props.dashboard} />;
|
||||
}
|
||||
|
||||
renderPanelOptions() {
|
||||
const { pluginExports, panel } = this.props;
|
||||
|
||||
if (pluginExports.PanelOptionsComponent) {
|
||||
const OptionsComponent = pluginExports.PanelOptionsComponent;
|
||||
return <OptionsComponent options={panel.getOptions()} onChange={this.onPanelOptionsChanged} />;
|
||||
} else {
|
||||
return <p>Visualization has no options</p>;
|
||||
}
|
||||
}
|
||||
|
||||
onPanelOptionsChanged = (options: any) => {
|
||||
this.props.panel.updateOptions(options);
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
renderVizTab() {
|
||||
return (
|
||||
<div className="viz-editor">
|
||||
<div className="viz-editor-col1">
|
||||
<VizTypePicker currentType={this.props.panel.type} onTypeChanged={this.props.onTypeChanged} />
|
||||
</div>
|
||||
<div className="viz-editor-col2">
|
||||
<h5 className="page-heading">Options</h5>
|
||||
{this.renderPanelOptions()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onChangeTab = (tab: PanelEditorTab) => {
|
||||
@ -81,28 +45,79 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
renderCurrentTab(activeTab: string) {
|
||||
const { panel, dashboard, onTypeChanged, plugin, angularPanel } = this.props;
|
||||
|
||||
switch (activeTab) {
|
||||
case 'advanced':
|
||||
return <GeneralTab panel={panel} />;
|
||||
case 'queries':
|
||||
return <QueriesTab panel={panel} dashboard={dashboard} />;
|
||||
case 'alert':
|
||||
return <AlertTab angularPanel={angularPanel} />;
|
||||
case 'visualization':
|
||||
return (
|
||||
<VisualizationTab
|
||||
panel={panel}
|
||||
dashboard={dashboard}
|
||||
plugin={plugin}
|
||||
onTypeChanged={onTypeChanged}
|
||||
angularPanel={angularPanel}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { location } = store.getState();
|
||||
const activeTab = location.query.tab || 'queries';
|
||||
const { plugin } = this.props;
|
||||
let activeTab = store.getState().location.query.tab || 'queries';
|
||||
|
||||
const tabs: PanelEditorTab[] = [
|
||||
{ id: 'queries', text: 'Queries' },
|
||||
{ id: 'visualization', text: 'Visualization' },
|
||||
{ id: 'advanced', text: 'Panel Options' },
|
||||
];
|
||||
|
||||
// handle panels that do not have queries tab
|
||||
if (plugin.exports.PanelCtrl) {
|
||||
if (!plugin.exports.PanelCtrl.prototype.onDataReceived) {
|
||||
// remove queries tab
|
||||
tabs.shift();
|
||||
// switch tab
|
||||
if (activeTab === 'queries') {
|
||||
activeTab = 'visualization';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.alertingEnabled && plugin.id === 'graph') {
|
||||
tabs.push({
|
||||
id: 'alert',
|
||||
text: 'Alert',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tabbed-view tabbed-view--new">
|
||||
<div className="tabbed-view-header">
|
||||
<ul className="gf-tabs">
|
||||
{this.tabs.map(tab => {
|
||||
return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
|
||||
})}
|
||||
</ul>
|
||||
<div className="panel-editor-container__editor">
|
||||
{
|
||||
// <div className="panel-editor__close">
|
||||
// <i className="fa fa-arrow-left" />
|
||||
// </div>
|
||||
// <div className="panel-editor-resizer">
|
||||
// <div className="panel-editor-resizer__handle">
|
||||
// <div className="panel-editor-resizer__handle-dots" />
|
||||
// </div>
|
||||
// </div>
|
||||
}
|
||||
|
||||
<button className="tabbed-view-close-btn" ng-click="ctrl.exitFullscreen();">
|
||||
<i className="fa fa-remove" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tabbed-view-body">
|
||||
{activeTab === 'queries' && this.renderQueriesTab()}
|
||||
{activeTab === 'visualization' && this.renderVizTab()}
|
||||
<div className="panel-editor-tabs">
|
||||
{tabs.map(tab => {
|
||||
return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
|
||||
})}
|
||||
</div>
|
||||
<div className="panel-editor__right">{this.renderCurrentTab(activeTab)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -116,15 +131,17 @@ interface TabItemParams {
|
||||
|
||||
function TabItem({ tab, activeTab, onClick }: TabItemParams) {
|
||||
const tabClasses = classNames({
|
||||
'gf-tabs-link': true,
|
||||
'panel-editor-tabs__link': true,
|
||||
active: activeTab === tab.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<li className="gf-tabs-item" key={tab.id}>
|
||||
<a className={tabClasses} onClick={() => onClick(tab)}>
|
||||
<i className={tab.icon} /> {tab.text}
|
||||
<div className="panel-editor-tabs__item" onClick={() => onClick(tab)}>
|
||||
<a className={tabClasses}>
|
||||
<Tooltip content={`${tab.text}`} className="popper__manager--block" placement="auto">
|
||||
<i className={`gicon gicon-${tab.id}${activeTab === tab.id ? '-active' : ''}`} />
|
||||
</Tooltip>
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,51 +1,88 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import PanelHeaderCorner from './PanelHeaderCorner';
|
||||
import { PanelHeaderMenu } from './PanelHeaderMenu';
|
||||
|
||||
import { DashboardModel } from 'app/features/dashboard/dashboard_model';
|
||||
import { PanelModel } from 'app/features/dashboard/panel_model';
|
||||
import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper';
|
||||
|
||||
export interface Props {
|
||||
panel: PanelModel;
|
||||
dashboard: DashboardModel;
|
||||
timeInfo: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
scopedVars?: string;
|
||||
links?: [];
|
||||
}
|
||||
|
||||
export class PanelHeader extends PureComponent<Props> {
|
||||
interface State {
|
||||
panelMenuOpen: boolean;
|
||||
}
|
||||
|
||||
export class PanelHeader extends Component<Props, State> {
|
||||
state = {
|
||||
panelMenuOpen: false,
|
||||
};
|
||||
|
||||
onMenuToggle = event => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.setState(prevState => ({
|
||||
panelMenuOpen: !prevState.panelMenuOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
closeMenu = () => {
|
||||
this.setState({
|
||||
panelMenuOpen: false,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const isFullscreen = false;
|
||||
const isLoading = false;
|
||||
const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
|
||||
const { panel, dashboard } = this.props;
|
||||
|
||||
const { panel, dashboard, timeInfo } = this.props;
|
||||
return (
|
||||
<div className={panelHeaderClass}>
|
||||
<span className="panel-info-corner">
|
||||
<i className="fa" />
|
||||
<span className="panel-info-corner-inner" />
|
||||
</span>
|
||||
|
||||
{isLoading && (
|
||||
<span className="panel-loading">
|
||||
<i className="fa fa-spinner fa-spin" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="panel-title-container">
|
||||
<div className="panel-title">
|
||||
<span className="icon-gf panel-alert-icon" />
|
||||
<span className="panel-title-text" data-toggle="dropdown">
|
||||
{panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
|
||||
<>
|
||||
<PanelHeaderCorner
|
||||
panel={panel}
|
||||
title={panel.title}
|
||||
description={panel.description}
|
||||
scopedVars={panel.scopedVars}
|
||||
links={panel.links}
|
||||
/>
|
||||
<div className={panelHeaderClass}>
|
||||
{isLoading && (
|
||||
<span className="panel-loading">
|
||||
<i className="fa fa-spinner fa-spin" />
|
||||
</span>
|
||||
)}
|
||||
<div className="panel-title-container" onClick={this.onMenuToggle}>
|
||||
<div className="panel-title">
|
||||
<span className="icon-gf panel-alert-icon" />
|
||||
<span className="panel-title-text">
|
||||
{panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
|
||||
</span>
|
||||
|
||||
<PanelHeaderMenu panel={panel} dashboard={dashboard} />
|
||||
{this.state.panelMenuOpen && (
|
||||
<ClickOutsideWrapper onClick={this.closeMenu}>
|
||||
<PanelHeaderMenu panel={panel} dashboard={dashboard} />
|
||||
</ClickOutsideWrapper>
|
||||
)}
|
||||
|
||||
<span className="panel-time-info">
|
||||
<i className="fa fa-clock-o" /> 4m
|
||||
</span>
|
||||
{timeInfo && (
|
||||
<span className="panel-time-info">
|
||||
<i className="fa fa-clock-o" /> {timeInfo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,94 @@
|
||||
import React, { Component } from 'react';
|
||||
import { PanelModel } from 'app/features/dashboard/panel_model';
|
||||
import Tooltip from 'app/core/components/Tooltip/Tooltip';
|
||||
import templateSrv from 'app/features/templating/template_srv';
|
||||
import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
|
||||
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv';
|
||||
import Remarkable from 'remarkable';
|
||||
|
||||
enum InfoModes {
|
||||
Error = 'Error',
|
||||
Info = 'Info',
|
||||
Links = 'Links',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
title?: string;
|
||||
description?: string;
|
||||
scopedVars?: string;
|
||||
links?: [];
|
||||
}
|
||||
|
||||
export class PanelHeaderCorner extends Component<Props> {
|
||||
timeSrv: TimeSrv = getTimeSrv();
|
||||
|
||||
getInfoMode = () => {
|
||||
const { panel } = this.props;
|
||||
if (!!panel.description) {
|
||||
return InfoModes.Info;
|
||||
}
|
||||
if (panel.links && panel.links.length) {
|
||||
return InfoModes.Links;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
getInfoContent = (): JSX.Element => {
|
||||
const { panel } = this.props;
|
||||
const markdown = panel.description;
|
||||
const linkSrv = new LinkSrv(templateSrv, this.timeSrv);
|
||||
const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars);
|
||||
const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown);
|
||||
|
||||
const html = (
|
||||
<div className="markdown-html">
|
||||
<div dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} />
|
||||
{panel.links &&
|
||||
panel.links.length > 0 && (
|
||||
<ul className="text-left">
|
||||
{panel.links.map((link, idx) => {
|
||||
const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars);
|
||||
return (
|
||||
<li key={idx}>
|
||||
<a className="panel-menu-link" href={info.href} target={info.target}>
|
||||
{info.title}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
render() {
|
||||
const infoMode: InfoModes | undefined = this.getInfoMode();
|
||||
|
||||
if (!infoMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{infoMode === InfoModes.Info || infoMode === InfoModes.Links ? (
|
||||
<Tooltip
|
||||
content={this.getInfoContent}
|
||||
className="popper__manager--block"
|
||||
refClassName={`panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<i className="fa" />
|
||||
<span className="panel-info-corner-inner" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PanelHeaderCorner;
|
@ -35,6 +35,7 @@ export class PanelHeaderMenu extends PureComponent<Props> {
|
||||
render() {
|
||||
const { dashboard, panel } = this.props;
|
||||
const menu = getPanelMenu(dashboard, panel);
|
||||
return <div className="panel-menu-container dropdown">{this.renderItems(menu)}</div>;
|
||||
|
||||
return <div className="panel-menu-container dropdown open">{this.renderItems(menu)}</div>;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,26 @@
|
||||
// Libraries
|
||||
import React, { SFC } from 'react';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
onClose?: () => void;
|
||||
children: JSX.Element | JSX.Element[];
|
||||
}
|
||||
|
||||
export const PanelOptionSection: SFC<Props> = props => {
|
||||
return (
|
||||
<div className="panel-option-section">
|
||||
{props.title && (
|
||||
<div className="panel-option-section__header">
|
||||
{props.title}
|
||||
{props.onClose && (
|
||||
<button className="btn btn-link" onClick={props.onClose}>
|
||||
<i className="fa fa-remove" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="panel-option-section__body">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,64 @@
|
||||
import _ from 'lodash';
|
||||
import React, { PureComponent } from 'react';
|
||||
import { PanelPlugin, PanelProps } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
class PanelPluginNotFound extends PureComponent<Props> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center' as 'center',
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div className="alert alert-error" style={{ margin: '0 auto' }}>
|
||||
Panel plugin with id {this.props.pluginId} could not be found
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getPanelPluginNotFound(id: string): PanelPlugin {
|
||||
const NotFound = class NotFound extends PureComponent<PanelProps> {
|
||||
render() {
|
||||
return <PanelPluginNotFound pluginId={id} />;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: id,
|
||||
sort: 100,
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
info: {
|
||||
author: {
|
||||
name: '',
|
||||
},
|
||||
description: '',
|
||||
links: [],
|
||||
logos: {
|
||||
large: '',
|
||||
small: '',
|
||||
},
|
||||
screenshots: [],
|
||||
updated: '',
|
||||
version: '',
|
||||
},
|
||||
|
||||
exports: {
|
||||
Panel: NotFound,
|
||||
},
|
||||
};
|
||||
}
|
@ -1,24 +1,79 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
import React, { SFC, PureComponent } from 'react';
|
||||
import Remarkable from 'remarkable';
|
||||
import _ from 'lodash';
|
||||
|
||||
// Services & utils
|
||||
// Components
|
||||
import './../../panel/metrics_tab';
|
||||
import { EditorTabBody } from './EditorTabBody';
|
||||
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
|
||||
import { QueryInspector } from './QueryInspector';
|
||||
import { QueryOptions } from './QueryOptions';
|
||||
import { AngularQueryComponentScope } from 'app/features/panel/metrics_tab';
|
||||
import { PanelOptionSection } from './PanelOptionSection';
|
||||
|
||||
// Services
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
|
||||
import config from 'app/core/config';
|
||||
|
||||
// Types
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { DashboardModel } from '../dashboard_model';
|
||||
import { DataSourceSelectItem, DataQuery } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
dashboard: DashboardModel;
|
||||
}
|
||||
|
||||
export class QueriesTab extends PureComponent<Props> {
|
||||
element: any;
|
||||
interface State {
|
||||
currentDS: DataSourceSelectItem;
|
||||
helpContent: JSX.Element;
|
||||
isLoadingHelp: boolean;
|
||||
isPickerOpen: boolean;
|
||||
isAddingMixed: boolean;
|
||||
}
|
||||
|
||||
interface LoadingPlaceholderProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const LoadingPlaceholder: SFC<LoadingPlaceholderProps> = ({ text }) => <h2>{text}</h2>;
|
||||
|
||||
export class QueriesTab extends PureComponent<Props, State> {
|
||||
element: HTMLElement;
|
||||
component: AngularComponent;
|
||||
datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
|
||||
backendSrv: BackendSrv = getBackendSrv();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { panel } = props;
|
||||
|
||||
this.state = {
|
||||
currentDS: this.datasources.find(datasource => datasource.value === panel.datasource),
|
||||
isLoadingHelp: false,
|
||||
helpContent: null,
|
||||
isPickerOpen: false,
|
||||
isAddingMixed: 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,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -26,16 +81,10 @@ export class QueriesTab extends PureComponent<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
const { panel, dashboard } = this.props;
|
||||
|
||||
const loader = getAngularLoader();
|
||||
const template = '<metrics-tab />';
|
||||
const scopeProps = {
|
||||
ctrl: {
|
||||
panel: panel,
|
||||
dashboard: dashboard,
|
||||
refresh: () => panel.refresh(),
|
||||
},
|
||||
ctrl: this.getAngularQueryComponentScope(),
|
||||
};
|
||||
|
||||
this.component = loader.load(this.element, scopeProps, template);
|
||||
@ -47,7 +96,190 @@ export class QueriesTab extends PureComponent<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
onChangeDataSource = datasource => {
|
||||
const { panel } = this.props;
|
||||
const { currentDS } = this.state;
|
||||
|
||||
// switching to mixed
|
||||
if (datasource.meta.mixed) {
|
||||
panel.targets.forEach(target => {
|
||||
target.datasource = panel.datasource;
|
||||
if (!target.datasource) {
|
||||
target.datasource = config.defaultDatasource;
|
||||
}
|
||||
});
|
||||
} else if (currentDS) {
|
||||
// if switching from mixed
|
||||
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.refresh();
|
||||
|
||||
this.setState({
|
||||
currentDS: datasource,
|
||||
});
|
||||
};
|
||||
|
||||
loadHelp = () => {
|
||||
const { currentDS } = this.state;
|
||||
const hasHelp = currentDS.meta.hasQueryHelp;
|
||||
|
||||
if (hasHelp) {
|
||||
this.setState({
|
||||
helpContent: <h3>Loading help...</h3>,
|
||||
isLoadingHelp: true,
|
||||
});
|
||||
|
||||
this.backendSrv
|
||||
.get(`/api/plugins/${currentDS.meta.id}/markdown/query_help`)
|
||||
.then(res => {
|
||||
const md = new Remarkable();
|
||||
const helpHtml = md.render(res);
|
||||
this.setState({
|
||||
helpContent: <div className="markdown-html" dangerouslySetInnerHTML={{ __html: helpHtml }} />,
|
||||
isLoadingHelp: false,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({
|
||||
helpContent: <h3>'Error occured when loading help'</h3>,
|
||||
isLoadingHelp: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
renderQueryInspector = () => {
|
||||
const { panel } = this.props;
|
||||
return <QueryInspector panel={panel} LoadingPlaceholder={LoadingPlaceholder} />;
|
||||
};
|
||||
|
||||
renderHelp = () => {
|
||||
const { helpContent, isLoadingHelp } = this.state;
|
||||
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} onChange={this.onChangeDataSource} current={currentDS} />;
|
||||
};
|
||||
|
||||
renderMixedPicker = () => {
|
||||
return (
|
||||
<DataSourcePicker
|
||||
datasources={this.datasources}
|
||||
onChange={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() {
|
||||
return <div ref={element => (this.element = element)} className="panel-height-helper" />;
|
||||
const { panel } = this.props;
|
||||
const { currentDS, isAddingMixed } = this.state;
|
||||
const { hasQueryHelp } = currentDS.meta;
|
||||
|
||||
const queryInspector = {
|
||||
title: 'Query Inspector',
|
||||
render: this.renderQueryInspector,
|
||||
};
|
||||
|
||||
const dsHelp = {
|
||||
heading: 'Help',
|
||||
icon: 'fa fa-question',
|
||||
disabled: !hasQueryHelp,
|
||||
onClick: this.loadHelp,
|
||||
render: this.renderHelp,
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorTabBody heading="Queries" renderToolbar={this.renderToolbar} toolbarItems={[queryInspector, dsHelp]}>
|
||||
<>
|
||||
<PanelOptionSection>
|
||||
<div className="query-editor-rows">
|
||||
<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>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
{!isAddingMixed && (
|
||||
<button className="btn btn-secondary gf-form-btn" onClick={this.onAddQueryClick}>
|
||||
Add Query
|
||||
</button>
|
||||
)}
|
||||
{isAddingMixed && this.renderMixedPicker()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelOptionSection>
|
||||
<PanelOptionSection>
|
||||
<QueryOptions panel={panel} datasource={currentDS} />
|
||||
</PanelOptionSection>
|
||||
</>
|
||||
</EditorTabBody>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
220
public/app/features/dashboard/dashgrid/QueryInspector.tsx
Normal file
220
public/app/features/dashboard/dashgrid/QueryInspector.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
|
||||
|
||||
interface DsQuery {
|
||||
isLoading: boolean;
|
||||
response: {};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
panel: any;
|
||||
LoadingPlaceholder: any;
|
||||
}
|
||||
|
||||
interface State {
|
||||
allNodesExpanded: boolean;
|
||||
isMocking: boolean;
|
||||
mockedResponse: string;
|
||||
dsQuery: DsQuery;
|
||||
}
|
||||
|
||||
export class QueryInspector extends PureComponent<Props, State> {
|
||||
formattedJson: any;
|
||||
clipboard: any;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
allNodesExpanded: null,
|
||||
isMocking: false,
|
||||
mockedResponse: '',
|
||||
dsQuery: {
|
||||
isLoading: false,
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { panel } = this.props;
|
||||
panel.events.on('refresh', this.onPanelRefresh);
|
||||
appEvents.on('ds-request-response', this.onDataSourceResponse);
|
||||
panel.refresh();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { panel } = this.props;
|
||||
appEvents.off('ds-request-response', this.onDataSourceResponse);
|
||||
panel.events.off('refresh', this.onPanelRefresh);
|
||||
}
|
||||
|
||||
handleMocking(response) {
|
||||
const { mockedResponse } = this.state;
|
||||
let mockedData;
|
||||
try {
|
||||
mockedData = JSON.parse(mockedResponse);
|
||||
} catch (err) {
|
||||
appEvents.emit('alert-error', ['R: Failed to parse mocked response']);
|
||||
return;
|
||||
}
|
||||
|
||||
response.data = mockedData;
|
||||
}
|
||||
|
||||
onPanelRefresh = () => {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
dsQuery: {
|
||||
isLoading: true,
|
||||
response: {},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
onDataSourceResponse = (response: any = {}) => {
|
||||
if (this.state.isMocking) {
|
||||
this.handleMocking(response);
|
||||
return;
|
||||
}
|
||||
|
||||
response = { ...response }; // clone - dont modify the response
|
||||
|
||||
if (response.headers) {
|
||||
delete response.headers;
|
||||
}
|
||||
|
||||
if (response.config) {
|
||||
response.request = response.config;
|
||||
delete response.config;
|
||||
delete response.request.transformRequest;
|
||||
delete response.request.transformResponse;
|
||||
delete response.request.paramSerializer;
|
||||
delete response.request.jsonpCallbackParam;
|
||||
delete response.request.headers;
|
||||
delete response.request.requestId;
|
||||
delete response.request.inspect;
|
||||
delete response.request.retry;
|
||||
delete response.request.timeout;
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
response.response = response.data;
|
||||
|
||||
delete response.data;
|
||||
delete response.status;
|
||||
delete response.statusText;
|
||||
delete response.$$config;
|
||||
}
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
dsQuery: {
|
||||
isLoading: false,
|
||||
response: response,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
setFormattedJson = formattedJson => {
|
||||
this.formattedJson = formattedJson;
|
||||
};
|
||||
|
||||
getTextForClipboard = () => {
|
||||
return JSON.stringify(this.formattedJson, null, 2);
|
||||
};
|
||||
|
||||
onClipboardSuccess = () => {
|
||||
appEvents.emit('alert-success', ['Content copied to clipboard']);
|
||||
};
|
||||
|
||||
onToggleExpand = () => {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
allNodesExpanded: !this.state.allNodesExpanded,
|
||||
}));
|
||||
};
|
||||
|
||||
onToggleMocking = () => {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
isMocking: !this.state.isMocking,
|
||||
}));
|
||||
};
|
||||
|
||||
getNrOfOpenNodes = () => {
|
||||
if (this.state.allNodesExpanded === null) {
|
||||
return 3; // 3 is default, ie when state is null
|
||||
} else if (this.state.allNodesExpanded) {
|
||||
return 20;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
setMockedResponse = evt => {
|
||||
const mockedResponse = evt.target.value;
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
mockedResponse,
|
||||
}));
|
||||
};
|
||||
|
||||
renderExpandCollapse = () => {
|
||||
const { allNodesExpanded } = this.state;
|
||||
|
||||
const collapse = (
|
||||
<>
|
||||
<i className="fa fa-minus-square-o" /> Collapse All
|
||||
</>
|
||||
);
|
||||
const expand = (
|
||||
<>
|
||||
<i className="fa fa-plus-square-o" /> Expand All
|
||||
</>
|
||||
);
|
||||
return allNodesExpanded ? collapse : expand;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { response, isLoading } = this.state.dsQuery;
|
||||
const { LoadingPlaceholder } = this.props;
|
||||
const { isMocking } = this.state;
|
||||
const openNodes = this.getNrOfOpenNodes();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingPlaceholder text="Loading query inspector..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pull-right">
|
||||
<button className="btn btn-transparent btn-p-x-0 m-r-1" onClick={this.onToggleExpand}>
|
||||
{this.renderExpandCollapse()}
|
||||
</button>
|
||||
<CopyToClipboard
|
||||
className="btn btn-transparent btn-p-x-0"
|
||||
text={this.getTextForClipboard}
|
||||
onSuccess={this.onClipboardSuccess}
|
||||
>
|
||||
<i className="fa fa-clipboard" /> Copy to Clipboard
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
|
||||
{!isMocking && <JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />}
|
||||
{isMocking && (
|
||||
<div className="query-troubleshooter__body">
|
||||
<div className="gf-form p-l-1 gf-form--v-stretch">
|
||||
<textarea
|
||||
className="gf-form-input"
|
||||
style={{ width: '95%' }}
|
||||
rows={10}
|
||||
onInput={this.setMockedResponse}
|
||||
placeholder="JSON"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
167
public/app/features/dashboard/dashgrid/QueryOptions.tsx
Normal file
167
public/app/features/dashboard/dashgrid/QueryOptions.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Utils
|
||||
import { isValidTimeSpan } from 'app/core/utils/rangeutil';
|
||||
|
||||
// Components
|
||||
import { Switch } from 'app/core/components/Switch/Switch';
|
||||
import { Input } from 'app/core/components/Form';
|
||||
import { EventsWithValidation } from 'app/core/components/Form/Input';
|
||||
import { InputStatus } from 'app/core/components/Form/Input';
|
||||
import DataSourceOption from './DataSourceOption';
|
||||
|
||||
// Types
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { ValidationEvents, DataSourceSelectItem } from 'app/types';
|
||||
|
||||
const timeRangeValidationEvents: ValidationEvents = {
|
||||
[EventsWithValidation.onBlur]: [
|
||||
{
|
||||
rule: value => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
return isValidTimeSpan(value);
|
||||
},
|
||||
errorMessage: 'Not a valid timespan',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const emptyToNull = (value: string) => {
|
||||
return value === '' ? null : value;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
datasource: DataSourceSelectItem;
|
||||
}
|
||||
|
||||
export class QueryOptions extends PureComponent<Props> {
|
||||
onOverrideTime = (evt, status: InputStatus) => {
|
||||
const { value } = evt.target;
|
||||
const { panel } = this.props;
|
||||
const emptyToNullValue = emptyToNull(value);
|
||||
if (status === InputStatus.Valid && panel.timeFrom !== emptyToNullValue) {
|
||||
panel.timeFrom = emptyToNullValue;
|
||||
panel.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
onTimeShift = (evt, status: InputStatus) => {
|
||||
const { value } = evt.target;
|
||||
const { panel } = this.props;
|
||||
const emptyToNullValue = emptyToNull(value);
|
||||
if (status === InputStatus.Valid && panel.timeShift !== emptyToNullValue) {
|
||||
panel.timeShift = emptyToNullValue;
|
||||
panel.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
onToggleTimeOverride = () => {
|
||||
const { panel } = this.props;
|
||||
panel.hideTimeOverride = !panel.hideTimeOverride;
|
||||
panel.refresh();
|
||||
};
|
||||
|
||||
renderOptions() {
|
||||
const { datasource, panel } = this.props;
|
||||
const { queryOptions } = datasource.meta;
|
||||
|
||||
if (!queryOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onChangeFn = (panelKey: string) => {
|
||||
return (value: string | number) => {
|
||||
panel[panelKey] = value;
|
||||
panel.refresh();
|
||||
};
|
||||
};
|
||||
|
||||
const allOptions = {
|
||||
cacheTimeout: {
|
||||
label: 'Cache timeout',
|
||||
placeholder: '60',
|
||||
name: 'cacheTimeout',
|
||||
value: panel.cacheTimeout,
|
||||
tooltipInfo: (
|
||||
<>
|
||||
If your time series store has a query cache this option can override the default cache timeout. Specify a
|
||||
numeric value in seconds.
|
||||
</>
|
||||
),
|
||||
},
|
||||
maxDataPoints: {
|
||||
label: 'Max data points',
|
||||
placeholder: 'auto',
|
||||
name: 'maxDataPoints',
|
||||
value: panel.maxDataPoints,
|
||||
tooltipInfo: (
|
||||
<>
|
||||
The maximum data points the query should return. For graphs this is automatically set to one data point per
|
||||
pixel.
|
||||
</>
|
||||
),
|
||||
},
|
||||
minInterval: {
|
||||
label: 'Min time interval',
|
||||
placeholder: '0',
|
||||
name: 'minInterval',
|
||||
value: panel.interval,
|
||||
panelKey: 'interval',
|
||||
tooltipInfo: (
|
||||
<>
|
||||
A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example{' '}
|
||||
<code>1m</code> if your data is written every minute. Access auto interval via variable{' '}
|
||||
<code>$__interval</code> for time range string and <code>$__interval_ms</code> for numeric variable that can
|
||||
be used in math expressions.
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return Object.keys(queryOptions).map(key => {
|
||||
const options = allOptions[key];
|
||||
return <DataSourceOption key={key} {...options} onChange={onChangeFn(allOptions[key].panelKey || key)} />;
|
||||
});
|
||||
}
|
||||
|
||||
render = () => {
|
||||
const hideTimeOverride = this.props.panel.hideTimeOverride;
|
||||
return (
|
||||
<div className="gf-form-inline">
|
||||
{this.renderOptions()}
|
||||
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label">Relative time</span>
|
||||
<Input
|
||||
type="text"
|
||||
className="width-6"
|
||||
placeholder="1h"
|
||||
onBlur={this.onOverrideTime}
|
||||
validationEvents={timeRangeValidationEvents}
|
||||
hideErrorMessage={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label">Time shift</span>
|
||||
<Input
|
||||
type="text"
|
||||
className="width-6"
|
||||
placeholder="1h"
|
||||
onBlur={this.onTimeShift}
|
||||
validationEvents={timeRangeValidationEvents}
|
||||
hideErrorMessage={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gf-form-inline">
|
||||
<Switch label="Hide time info" checked={hideTimeOverride} onChange={this.onToggleTimeOverride} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
221
public/app/features/dashboard/dashgrid/VisualizationTab.tsx
Normal file
221
public/app/features/dashboard/dashgrid/VisualizationTab.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Utils & Services
|
||||
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
|
||||
|
||||
// Components
|
||||
import { EditorTabBody } from './EditorTabBody';
|
||||
import { VizTypePicker } from './VizTypePicker';
|
||||
import { FadeIn } from 'app/core/components/Animations/FadeIn';
|
||||
import { PanelOptionSection } from './PanelOptionSection';
|
||||
|
||||
// Types
|
||||
import { PanelModel } from '../panel_model';
|
||||
import { DashboardModel } from '../dashboard_model';
|
||||
import { PanelPlugin } from 'app/types/plugins';
|
||||
|
||||
interface Props {
|
||||
panel: PanelModel;
|
||||
dashboard: DashboardModel;
|
||||
plugin: PanelPlugin;
|
||||
angularPanel?: AngularComponent;
|
||||
onTypeChanged: (newType: PanelPlugin) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isVizPickerOpen: boolean;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export class VisualizationTab extends PureComponent<Props, State> {
|
||||
element: HTMLElement;
|
||||
angularOptions: AngularComponent;
|
||||
searchInput: HTMLElement;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isVizPickerOpen: false,
|
||||
searchQuery: '',
|
||||
};
|
||||
}
|
||||
|
||||
getPanelDefaultOptions = () => {
|
||||
const { panel, plugin } = this.props;
|
||||
|
||||
if (plugin.exports.PanelDefaults) {
|
||||
return panel.getOptions(plugin.exports.PanelDefaults.options);
|
||||
}
|
||||
|
||||
return panel.getOptions(plugin.exports.PanelDefaults);
|
||||
};
|
||||
|
||||
renderPanelOptions() {
|
||||
const { plugin, angularPanel } = this.props;
|
||||
const { PanelOptions } = plugin.exports;
|
||||
|
||||
if (angularPanel) {
|
||||
return <div ref={element => (this.element = element)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelOptionSection>
|
||||
{PanelOptions ? (
|
||||
<PanelOptions options={this.getPanelDefaultOptions()} onChange={this.onPanelOptionsChanged} />
|
||||
) : (
|
||||
<p>Visualization has no options</p>
|
||||
)}
|
||||
</PanelOptionSection>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.shouldLoadAngularOptions()) {
|
||||
this.loadAngularOptions();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.props.plugin !== prevProps.plugin) {
|
||||
this.cleanUpAngularOptions();
|
||||
}
|
||||
|
||||
if (this.shouldLoadAngularOptions()) {
|
||||
this.loadAngularOptions();
|
||||
}
|
||||
}
|
||||
|
||||
shouldLoadAngularOptions() {
|
||||
return this.props.angularPanel && this.element && !this.angularOptions;
|
||||
}
|
||||
|
||||
loadAngularOptions() {
|
||||
const { angularPanel } = this.props;
|
||||
|
||||
const scope = angularPanel.getScope();
|
||||
|
||||
// When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
|
||||
if (!scope.$$childHead) {
|
||||
setTimeout(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const panelCtrl = scope.$$childHead.ctrl;
|
||||
|
||||
let template = '';
|
||||
for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
|
||||
template +=
|
||||
`
|
||||
<div class="panel-option-section" ng-cloak>` +
|
||||
(i > 0 ? `<div class="panel-option-section__header">{{ctrl.editorTabs[${i}].title}}</div>` : '') +
|
||||
`<div class="panel-option-section__body">
|
||||
<panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const loader = getAngularLoader();
|
||||
const scopeProps = { ctrl: panelCtrl };
|
||||
|
||||
this.angularOptions = loader.load(this.element, scopeProps, template);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.cleanUpAngularOptions();
|
||||
}
|
||||
|
||||
cleanUpAngularOptions() {
|
||||
if (this.angularOptions) {
|
||||
this.angularOptions.destroy();
|
||||
this.angularOptions = null;
|
||||
}
|
||||
}
|
||||
|
||||
onPanelOptionsChanged = (options: any) => {
|
||||
this.props.panel.updateOptions(options);
|
||||
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>
|
||||
<button className="btn btn-link toolbar__close" 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() {
|
||||
const { plugin } = this.props;
|
||||
const { isVizPickerOpen, searchQuery } = this.state;
|
||||
|
||||
return (
|
||||
<EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar}>
|
||||
<>
|
||||
<FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true}>
|
||||
<VizTypePicker
|
||||
current={plugin}
|
||||
onTypeChanged={this.onTypeChanged}
|
||||
searchQuery={searchQuery}
|
||||
onClose={this.onCloseVizPicker}
|
||||
/>
|
||||
</FadeIn>
|
||||
{this.renderPanelOptions()}
|
||||
</>
|
||||
</EditorTabBody>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,29 +1,31 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import config from 'app/core/config';
|
||||
import { PanelPlugin } from 'app/types/plugins';
|
||||
import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
|
||||
import _ from 'lodash';
|
||||
|
||||
interface Props {
|
||||
currentType: string;
|
||||
import config from 'app/core/config';
|
||||
import { PanelPlugin } from 'app/types/plugins';
|
||||
import VizTypePickerPlugin from './VizTypePickerPlugin';
|
||||
|
||||
export interface Props {
|
||||
current: PanelPlugin;
|
||||
onTypeChanged: (newType: PanelPlugin) => void;
|
||||
searchQuery: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
pluginList: PanelPlugin[];
|
||||
}
|
||||
export class VizTypePicker extends PureComponent<Props> {
|
||||
searchInput: HTMLElement;
|
||||
pluginList = this.getPanelPlugins('');
|
||||
|
||||
export class VizTypePicker extends PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
pluginList: this.getPanelPlugins(''),
|
||||
};
|
||||
}
|
||||
|
||||
getPanelPlugins(filter) {
|
||||
get maxSelectedIndex() {
|
||||
const filteredPluginList = this.getFilteredPluginList();
|
||||
return filteredPluginList.length - 1;
|
||||
}
|
||||
|
||||
getPanelPlugins(filter): PanelPlugin[] {
|
||||
const panels = _.chain(config.panels)
|
||||
.filter({ hideFromList: false })
|
||||
.map(item => item)
|
||||
@ -33,35 +35,39 @@ export class VizTypePicker extends PureComponent<Props, State> {
|
||||
return _.sortBy(panels, 'sort');
|
||||
}
|
||||
|
||||
renderVizPlugin = (plugin, index) => {
|
||||
const cssClass = classNames({
|
||||
'viz-picker__item': true,
|
||||
'viz-picker__item--selected': plugin.id === this.props.currentType,
|
||||
});
|
||||
renderVizPlugin = (plugin: PanelPlugin, index: number) => {
|
||||
const { onTypeChanged } = this.props;
|
||||
const isCurrent = plugin.id === this.props.current.id;
|
||||
|
||||
return (
|
||||
<div key={index} className={cssClass} onClick={() => this.props.onTypeChanged(plugin)} title={plugin.name}>
|
||||
<img className="viz-picker__item-img" src={plugin.info.logos.small} />
|
||||
<div className="viz-picker__item-name">{plugin.name}</div>
|
||||
</div>
|
||||
<VizTypePickerPlugin
|
||||
key={plugin.id}
|
||||
isCurrent={isCurrent}
|
||||
plugin={plugin}
|
||||
onClick={() => onTypeChanged(plugin)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
getFilteredPluginList = (): PanelPlugin[] => {
|
||||
const { searchQuery } = this.props;
|
||||
const regex = new RegExp(searchQuery, 'i');
|
||||
const pluginList = this.pluginList;
|
||||
|
||||
const filtered = pluginList.filter(item => {
|
||||
return regex.test(item.name);
|
||||
});
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
render() {
|
||||
const filteredPluginList = this.getFilteredPluginList();
|
||||
|
||||
return (
|
||||
<div className="viz-picker">
|
||||
<div className="viz-picker__search">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<label className="gf-form--has-input-icon gf-form--grow">
|
||||
<input type="text" className="gf-form-input" placeholder="Search type" />
|
||||
<i className="gf-form-input-icon fa fa-search" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="viz-picker__items">
|
||||
<CustomScrollbar>
|
||||
<div className="scroll-margin-helper">{this.state.pluginList.map(this.renderVizPlugin)}</div>
|
||||
</CustomScrollbar>
|
||||
<div className="viz-picker-list">
|
||||
{filteredPluginList.map((plugin, index) => this.renderVizPlugin(plugin, index))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user