Commit 6cbbffff by Torkel Ödegaard Committed by GitHub

Merge pull request #14198 from grafana/panel-edit-in-react-query-inspector

Panel edit in react query inspector
parents 3d45b5ba a8e184c0
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
);
}
}
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>
);
}
}
import React, { PureComponent } from 'react';
import { ValidationEvents, ValidationRule } from 'app/types';
import { validate } 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> {
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.currentTarget.value, validationRules);
this.setState(prevState => {
return {
...prevState,
error: errors ? errors[0] : null,
};
});
};
};
populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => {
const inputElementProps = { ...restProps };
Object.keys(EventsWithValidation).forEach(eventName => {
inputElementProps[eventName] = async evt => {
if (validationEvents[eventName]) {
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 = 'gf-form-input' + (this.isInvalid ? ' invalid' : '');
const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents);
return (
<div className="our-custom-wrapper-class">
<input {...inputElementProps} className={inputClassName} />
{error && !hideErrorMessage && <span>{error}</span>}
</div>
);
}
}
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>
);
}
}
export { Element } from './Element';
export { Input } from './Input';
export { Label } from './Label';
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} />;
}
}
......@@ -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;
};
import { ValidationRule } from 'app/types';
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;
};
import React, { PureComponent } from 'react';
import React, { SFC, PureComponent } from 'react';
import DataSourceOption from './DataSourceOption';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
import { EditorTabBody } from './EditorTabBody';
import { DataSourcePicker } from './DataSourcePicker';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import './../../panel/metrics_tab';
import config from 'app/core/config';
import { QueryInspector } from './QueryInspector';
import { Switch } from 'app/core/components/Switch/Switch';
import { Input } from 'app/core/components/Form';
import { InputStatus, EventsWithValidation } from 'app/core/components/Form/Input';
import { isValidTimeSpan } from 'app/core/utils/rangeutil';
import { ValidationEvents } from 'app/types';
// Services
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
......@@ -29,8 +34,29 @@ interface Help {
interface State {
currentDatasource: DataSourceSelectItem;
help: Help;
hideTimeOverride: boolean;
}
interface LoadingPlaceholderProps {
text: string;
}
const LoadingPlaceholder: SFC<LoadingPlaceholderProps> = ({ text }) => <h2>{text}</h2>;
const timeRangeValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [
{
rule: value => {
if (!value) {
return true;
}
return isValidTimeSpan(value);
},
errorMessage: 'Not a valid timespan',
},
],
};
export class QueriesTab extends PureComponent<Props, State> {
element: any;
component: AngularComponent;
......@@ -47,6 +73,7 @@ export class QueriesTab extends PureComponent<Props, State> {
isLoading: false,
helpHtml: null,
},
hideTimeOverride: false,
};
}
......@@ -199,9 +226,50 @@ export class QueriesTab extends PureComponent<Props, State> {
});
};
renderQueryInspector = () => {
const { panel } = this.props;
return <QueryInspector panel={panel} LoadingPlaceholder={LoadingPlaceholder} />;
};
renderHelp = () => {
const { helpHtml, isLoading } = this.state.help;
return isLoading ? <LoadingPlaceholder text="Loading help..." /> : helpHtml;
};
emptyToNull = (value: string) => {
return value === '' ? null : value;
};
onOverrideTime = (evt, status: InputStatus) => {
const { value } = evt.target;
const { panel } = this.props;
const emptyToNullValue = this.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 = this.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();
};
render() {
const { currentDatasource } = this.state;
const { helpHtml } = this.state.help;
const hideTimeOverride = this.props.panel.hideTimeOverride;
console.log('hideTimeOverride', hideTimeOverride);
const { hasQueryHelp, queryOptions } = currentDatasource.meta;
const hasQueryOptions = !!queryOptions;
const dsInformation = {
......@@ -220,7 +288,7 @@ export class QueriesTab extends PureComponent<Props, State> {
const queryInspector = {
title: 'Query Inspector',
render: () => <h2>hello</h2>,
render: this.renderQueryInspector,
};
const dsHelp = {
......@@ -228,18 +296,67 @@ export class QueriesTab extends PureComponent<Props, State> {
icon: 'fa fa-question',
disabled: !hasQueryHelp,
onClick: this.loadHelp,
render: () => helpHtml,
render: this.renderHelp,
};
const options = {
title: 'Options',
title: '',
icon: 'fa fa-cog',
disabled: !hasQueryOptions,
render: this.renderOptions,
};
return (
<EditorTabBody heading="Queries" main={dsInformation} toolbarItems={[options, queryInspector, dsHelp]}>
<div ref={element => (this.element = element)} style={{ width: '100%' }} />
<>
<div ref={element => (this.element = element)} style={{ width: '100%' }} />
<h5 className="section-heading">Time Range</h5>
<div className="gf-form-group">
<div className="gf-form">
<span className="gf-form-label">
<i className="fa fa-clock-o" />
</span>
<span className="gf-form-label width-12">Override relative time</span>
<span className="gf-form-label width-6">Last</span>
<Input
type="text"
className="gf-form-input max-width-8"
placeholder="1h"
onBlur={this.onOverrideTime}
validationEvents={timeRangeValidationEvents}
hideErrorMessage={true}
/>
</div>
<div className="gf-form">
<span className="gf-form-label">
<i className="fa fa-clock-o" />
</span>
<span className="gf-form-label width-12">Add time shift</span>
<span className="gf-form-label width-6">Amount</span>
<Input
type="text"
className="gf-form-input max-width-8"
placeholder="1h"
onBlur={this.onTimeShift}
validationEvents={timeRangeValidationEvents}
hideErrorMessage={true}
/>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<span className="gf-form-label">
<i className="fa fa-clock-o" />
</span>
</div>
<Switch label="Hide time override info" checked={hideTimeOverride} onChange={this.onToggleTimeOverride} />
</div>
</div>
</>
</EditorTabBody>
);
}
......
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>
{/*
<button className="btn btn-transparent btn-p-x-0 m-r-1" onClick={this.onToggleMocking}>
Mock response
</button>
*/}
<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>
)}
</>
);
}
}
export interface ValidationRule {
rule: (valueToValidate: string) => boolean;
errorMessage: string;
}
export interface ValidationEvents {
[eventName: string]: ValidationRule[];
}
......@@ -30,7 +30,7 @@ import {
AppNotificationTimeout,
} from './appNotifications';
import { DashboardSearchHit } from './search';
import { ValidationEvents, ValidationRule } from './form';
export {
Team,
TeamsState,
......@@ -89,6 +89,8 @@ export {
AppNotificationTimeout,
DashboardSearchHit,
UserState,
ValidationEvents,
ValidationRule,
};
export interface StoreState {
......
......@@ -172,6 +172,12 @@
padding-right: 20px;
}
// No horizontal padding
.btn-p-x-0 {
padding-left: 0;
padding-right: 0;
}
// External services
// Usage:
// <div class="btn btn-service btn-service--facebook">Button text</div>
......
input[type="text"].ng-dirty.ng-invalid {
input[type='text'].ng-dirty.ng-invalid {
}
input.validation-error,
input.ng-dirty.ng-invalid {
box-shadow: inset 0 0px 5px $red;
}
input.invalid {
box-shadow: inset 0 0px 5px $red;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment