Compare commits

..

14 Commits

Author SHA1 Message Date
Sofia Papagiannaki
3a2bfb7e38 Release 6.4.3 2019-10-16 12:32:09 +03:00
Andrej Ocenas
80431256ca DataLinks: Fix url field not releasing focus (#19804)
(cherry picked from commit 09a599900c)
2019-10-16 12:32:09 +03:00
Abhilash Gnan
af364fb91d Alerting: All notification channels should always send (#19807)
Fixes so that all notification channels configured for an alert should 
try to send notification even if one notification channel fails to send
a notification.

Signed-off-by: Abhilash Gnan <abhilashgnan@gmail.com>

Fixes #19768

(cherry picked from commit 7f702f881c)
2019-10-16 12:32:09 +03:00
Dominik Prokop
90fe6c5a9f @grafana/toolkit: Check if git user.name config is set (#19821)
(cherry picked from commit 2e18930285)
2019-10-16 12:32:09 +03:00
Andrej Ocenas
399dd583da Fix: clicking outside of some query editors required 2 clicks (#19822)
(cherry picked from commit 5cf5d89dff)
2019-10-16 12:32:09 +03:00
Huan Wang
475a0baee1 grafana/toolkit: Add font file loader (#19781)
* add file loader for fonts

* Add public path to resolve fonts correctly

* Do not specify font's output path

* Output fonts to fonts directory

(cherry picked from commit 7da2156237)
2019-10-16 12:32:09 +03:00
Andrej Ocenas
f3e7f878d6 Call next in azure editor (#19799)
(cherry picked from commit 14cf2a3514)
2019-10-16 12:32:09 +03:00
Ryan McKinley
a364a86855 grafana/toolkit: Use http rather than ssh for git checkout (#19754)
(cherry picked from commit 3ca01c3255)
2019-10-16 12:32:09 +03:00
Dominik Prokop
223f30c71f DataLinks: Fix context menu not showing in singlestat-ish visualisations (#19809)
* Fix data links menu being hidden in siglestat-ish visualizations

* ts fix

* Review updates

(cherry picked from commit 00d0640b6e)
2019-10-16 12:32:09 +03:00
Hugo Häggmark
f53c6ebb65 Panels: Fixes default tab for visualizations without Queries Tab (#19803)
Fixes #19762

(cherry picked from commit 24b475e414)
2019-10-16 12:32:09 +03:00
Huan Wang
bcbe45a745 toolkit linter line number off by one (#19782)
it is actually an intended feature by tslint:
https://github.com/palantir/tslint/issues/4528

So adding 1 to the line number here in the plugin

(cherry picked from commit 7562959e44)
2019-10-16 12:32:09 +03:00
Torkel Ödegaard
1e32d7bced Singlestat: Fixed issue with mapping null to text (#19689)
(cherry picked from commit 22fbaa7ac8)
2019-10-16 12:32:09 +03:00
Miguel Carvajal
fed38ea617 Graph: make ContextMenu potitioning aware of the viewport width (#19699)
(cherry picked from commit c2749052d7)
2019-10-16 12:32:09 +03:00
Dominik Prokop
e40dd982c6 Docs: Add styling.md with guide to Emotion at Grafana (#19411)
* Add styling.md with guide to emotion

* Update style_guides/styling.md

* Update style_guides/styling.md

* Update style_guides/styling.md

* Update PR guide

* Add stylesFactory helper function

* Simplify styles creator signature

* Make styles factory deps optional

* Update typing

* First batch of updates

* Remove unused import

* Update tests

(cherry picked from commit 75b21c7603)
2019-10-16 12:32:09 +03:00
44 changed files with 591 additions and 296 deletions

View File

@@ -43,7 +43,7 @@ Whether you are contributing or doing code review, first read and understand htt
### Low-level checks
- [ ] The pull request contains a title that explains it. It follows [PR and commit messages guidelines](#Pull-Requests-titles-and-message).
- [ ] The pull request contains necessary links to issues.
- [ ] The pull request contains necessary links to issues.
- [ ] The pull request contains commits with messages that are small and understandable. It follows [PR and commit messages guidelines](#Pull-Requests-titles-and-message).
- [ ] The pull request does not contain magic strings or numbers that could be replaced with an `Enum` or `const` instead.
@@ -59,6 +59,8 @@ Whether you are contributing or doing code review, first read and understand htt
- [ ] The pull request does not contain uses of `any` or `{}` without comments describing why.
- [ ] The pull request does not contain large React components that could easily be split into several smaller components.
- [ ] The pull request does not contain back end calls directly from components, use actions and Redux instead.
- [ ] The pull request follows our [styling with Emotion convention](./style_guides/styling.md)
> We still use a lot of SASS, but any new CSS work should be using or migrating existing code to Emotion
#### Redux specific checks (skip if your pull request does not contain Redux changes)

View File

@@ -2,5 +2,5 @@
"npmClient": "yarn",
"useWorkspaces": true,
"packages": ["packages/*"],
"version": "6.4.2"
"version": "6.4.3"
}

View File

@@ -3,7 +3,7 @@
"license": "Apache-2.0",
"private": true,
"name": "grafana",
"version": "6.4.2",
"version": "6.4.3",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/data",
"version": "6.4.2",
"version": "6.4.3",
"description": "Grafana Data Library",
"keywords": [
"typescript"

View File

@@ -116,11 +116,11 @@ describe('Stats Calculators', () => {
},
{
data: [null, null, null], // All null
result: undefined,
result: null,
},
{
data: [undefined, undefined, undefined], // Empty row
result: undefined,
result: null,
},
];

View File

@@ -236,8 +236,8 @@ function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: boolean
mean: null,
last: null,
first: null,
lastNotNull: undefined,
firstNotNull: undefined,
lastNotNull: null,
firstNotNull: null,
count: 0,
nonNullCount: 0,
allIsNull: true,
@@ -272,8 +272,8 @@ function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: boolean
}
}
if (currentValue !== null) {
const isFirst = calcs.firstNotNull === undefined;
if (currentValue !== null && currentValue !== undefined) {
const isFirst = calcs.firstNotNull === null;
if (isFirst) {
calcs.firstNotNull = currentValue;
}
@@ -366,11 +366,11 @@ function calculateFirstNotNull(field: Field, ignoreNulls: boolean, nullAsZero: b
const data = field.values;
for (let idx = 0; idx < data.length; idx++) {
const v = data.get(idx);
if (v != null) {
if (v != null && v !== undefined) {
return { firstNotNull: v };
}
}
return { firstNotNull: undefined };
return { firstNotNull: null };
}
function calculateLast(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
@@ -383,11 +383,11 @@ function calculateLastNotNull(field: Field, ignoreNulls: boolean, nullAsZero: bo
let idx = data.length - 1;
while (idx >= 0) {
const v = data.get(idx--);
if (v != null) {
if (v != null && v !== undefined) {
return { lastNotNull: v };
}
}
return { lastNotNull: undefined };
return { lastNotNull: null };
}
function calculateChangeCount(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/runtime",
"version": "6.4.2",
"version": "6.4.3",
"description": "Grafana Runtime Library",
"keywords": [
"grafana",
@@ -21,8 +21,8 @@
"build": "grafana-toolkit package:build --scope=runtime"
},
"dependencies": {
"@grafana/data": "6.4.2",
"@grafana/ui": "6.4.2",
"@grafana/data": "6.4.3",
"@grafana/ui": "6.4.3",
"systemjs": "0.20.19",
"systemjs-plugin-css": "0.1.37"
},

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/toolkit",
"version": "6.4.2",
"version": "6.4.3",
"description": "Grafana Toolkit",
"keywords": [
"grafana",
@@ -28,8 +28,8 @@
"dependencies": {
"@babel/core": "7.4.5",
"@babel/preset-env": "7.4.5",
"@grafana/data": "6.4.2",
"@grafana/ui": "6.4.2",
"@grafana/data": "6.4.3",
"@grafana/ui": "6.4.3",
"@types/command-exists": "^1.2.0",
"@types/execa": "^0.9.0",
"@types/expect-puppeteer": "3.3.1",

View File

@@ -152,9 +152,11 @@ export const lintPlugin = useSpinner<Fixable>('Linting', async ({ fix }) => {
failures.forEach(f => {
// tslint:disable-next-line
console.log(
`${f.getRuleSeverity() === 'warning' ? 'WARNING' : 'ERROR'}: ${f.getFileName().split('src')[1]}[${
f.getStartPosition().getLineAndCharacter().line
}:${f.getStartPosition().getLineAndCharacter().character}]: ${f.getFailure()}`
`${f.getRuleSeverity() === 'warning' ? 'WARNING' : 'ERROR'}: ${
f.getFileName().split('src')[1]
}[${f.getStartPosition().getLineAndCharacter().line + 1}:${
f.getStartPosition().getLineAndCharacter().character
}]: ${f.getFailure()}`
);
});
console.log('\n');

View File

@@ -24,12 +24,15 @@ interface PluginDetails {
type PluginType = 'angular-panel' | 'react-panel' | 'datasource-plugin';
const RepositoriesPaths = {
'angular-panel': 'git@github.com:grafana/simple-angular-panel.git',
'react-panel': 'git@github.com:grafana/simple-react-panel.git',
'datasource-plugin': 'git@github.com:grafana/simple-datasource.git',
'angular-panel': 'https://github.com/grafana/simple-angular-panel.git',
'react-panel': 'https://github.com/grafana/simple-react-panel.git',
'datasource-plugin': 'https://github.com/grafana/simple-datasource.git',
};
export const getGitUsername = async () => await simpleGit.raw(['config', '--global', 'user.name']);
export const getGitUsername = async () => {
const name = await simpleGit.raw(['config', '--global', 'user.name']);
return name || '';
};
export const getPluginIdFromName = (name: string) => kebabCase(name);
export const getPluginId = (pluginDetails: PluginDetails) =>
`${kebabCase(pluginDetails.org)}-${getPluginIdFromName(pluginDetails.name)}`;

View File

@@ -0,0 +1,11 @@
import path from 'path';
let PLUGIN_ID: string;
export const getPluginId = () => {
if (!PLUGIN_ID) {
const pluginJson = require(path.resolve(process.cwd(), 'src/plugin.json'));
PLUGIN_ID = pluginJson.id;
}
return PLUGIN_ID;
};

View File

@@ -1,5 +1,6 @@
import fs from 'fs';
import path from 'path';
import { getPluginId } from '../utils/getPluginId';
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
@@ -139,5 +140,14 @@ export const getFileLoaders = () => {
},
],
},
{
test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'file-loader',
options: {
publicPath: `/public/plugins/${getPluginId()}/fonts`,
outputPath: 'fonts',
name: '[name].[ext]',
},
},
];
};

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/ui",
"version": "6.4.2",
"version": "6.4.3",
"description": "Grafana Components Library",
"keywords": [
"grafana",
@@ -25,7 +25,7 @@
"build": "grafana-toolkit package:build --scope=ui"
},
"dependencies": {
"@grafana/data": "6.4.2",
"@grafana/data": "6.4.3",
"@grafana/slate-react": "0.22.9-grafana",
"@torkelo/react-select": "2.1.1",
"@types/react-color": "2.17.0",

View File

@@ -9,6 +9,7 @@ import { getColorFromHexRgbOrName } from '../../utils';
// Types
import { Themeable } from '../../types';
import { stylesFactory } from '../../themes/stylesFactory';
export interface BigValueSparkline {
data: any[][]; // [[number,number]]
@@ -31,6 +32,33 @@ export interface Props extends Themeable {
className?: string;
}
const getStyles = stylesFactory(() => {
return {
wrapper: css`
position: 'relative';
display: 'table';
`,
title: css`
line-height: 1;
text-align: 'center';
z-index: 1;
display: 'block';
width: '100%';
position: 'absolute';
`,
value: css`
line-height: 1;
text-align: 'center';
z-index: 1;
display: 'table-cell';
vertical-align: 'middle';
position: 'relative';
font-size: '3em';
font-weight: 500;
`,
};
});
/*
* This visualization is still in POC state, needed more tests & better structure
*/
@@ -122,46 +150,12 @@ export class BigValue extends PureComponent<Props> {
render() {
const { height, width, value, prefix, suffix, sparkline, backgroundColor, onClick, className } = this.props;
const styles = getStyles();
return (
<div
className={cx(
css({
position: 'relative',
display: 'table',
}),
className
)}
style={{ width, height, backgroundColor }}
onClick={onClick}
>
{value.title && (
<div
className={css({
lineHeight: 1,
textAlign: 'center',
zIndex: 1,
display: 'block',
width: '100%',
position: 'absolute',
})}
>
{value.title}
</div>
)}
<div className={cx(styles.wrapper, className)} style={{ width, height, backgroundColor }} onClick={onClick}>
{value.title && <div className={styles.title}>{value.title}</div>}
<span
className={css({
lineHeight: 1,
textAlign: 'center',
zIndex: 1,
display: 'table-cell',
verticalAlign: 'middle',
position: 'relative',
fontSize: '3em',
fontWeight: 500, // TODO: $font-weight-semi-bold
})}
>
<span className={styles.value}>
{this.renderText(prefix, '0px 2px 0px 0px')}
{this.renderText(value)}
{this.renderText(suffix)}

View File

@@ -3,6 +3,7 @@ import tinycolor from 'tinycolor2';
import { css, cx } from 'emotion';
import { Themeable, GrafanaTheme } from '../../types';
import { selectThemeVariant } from '../../themes/selectThemeVariant';
import { stylesFactory } from '../../themes/stylesFactory';
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'inverse' | 'transparent';
@@ -49,7 +50,13 @@ const buttonVariantStyles = (
}
`;
const getButtonStyles = (theme: GrafanaTheme, size: ButtonSize, variant: ButtonVariant, withIcon: boolean) => {
interface StyleDeps {
theme: GrafanaTheme;
size: ButtonSize;
variant: ButtonVariant;
withIcon: boolean;
}
const getButtonStyles = stylesFactory(({ theme, size, variant, withIcon }: StyleDeps) => {
const borderRadius = theme.border.radius.sm;
let padding,
background,
@@ -155,7 +162,7 @@ const getButtonStyles = (theme: GrafanaTheme, size: ButtonSize, variant: ButtonV
filter: brightness(100);
`,
};
};
});
export const AbstractButton: React.FunctionComponent<AbstractButtonProps> = ({
renderAs,
@@ -167,7 +174,7 @@ export const AbstractButton: React.FunctionComponent<AbstractButtonProps> = ({
children,
...otherProps
}) => {
const buttonStyles = getButtonStyles(theme, size, variant, !!icon);
const buttonStyles = getButtonStyles({ theme, size, variant, withIcon: !!icon });
const nonHtmlProps = {
theme,
size,

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Themeable, GrafanaTheme } from '../../types/theme';
import { selectThemeVariant } from '../../themes/selectThemeVariant';
import { css, cx } from 'emotion';
import { stylesFactory } from '../../themes';
export interface CallToActionCardProps extends Themeable {
message?: string | JSX.Element;
@@ -10,7 +11,7 @@ export interface CallToActionCardProps extends Themeable {
className?: string;
}
const getCallToActionCardStyles = (theme: GrafanaTheme) => ({
const getCallToActionCardStyles = stylesFactory((theme: GrafanaTheme) => ({
wrapper: css`
label: call-to-action-card;
padding: ${theme.spacing.lg};
@@ -28,7 +29,7 @@ const getCallToActionCardStyles = (theme: GrafanaTheme) => ({
footer: css`
margin-top: ${theme.spacing.lg};
`,
});
}));
export const CallToActionCard: React.FunctionComponent<CallToActionCardProps> = ({
message,

View File

@@ -3,9 +3,10 @@ import { css, cx } from 'emotion';
import { GrafanaTheme } from '../../types/theme';
import { selectThemeVariant } from '../../themes/selectThemeVariant';
import { ThemeContext } from '../../themes/index';
import { ThemeContext } from '../../themes/ThemeContext';
import { stylesFactory } from '../../themes/stylesFactory';
const getStyles = (theme: GrafanaTheme) => ({
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
collapse: css`
label: collapse;
margin-top: ${theme.spacing.sm};
@@ -79,7 +80,7 @@ const getStyles = (theme: GrafanaTheme) => ({
font-size: ${theme.typography.heading.h6};
box-shadow: ${selectThemeVariant({ light: 'none', dark: '1px 1px 4px rgb(45, 45, 45)' }, theme.type)};
`,
});
}));
interface Props {
isOpen: boolean;

View File

@@ -1,7 +1,8 @@
import React, { useContext, useRef } from 'react';
import React, { useContext, useRef, useState, useLayoutEffect } from 'react';
import { css, cx } from 'emotion';
import useClickAway from 'react-use/lib/useClickAway';
import { GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index';
import { stylesFactory } from '../../themes/stylesFactory';
import { Portal, List } from '../index';
import { LinkTarget } from '@grafana/data';
@@ -26,7 +27,7 @@ export interface ContextMenuProps {
renderHeader?: () => JSX.Element;
}
const getContextMenuStyles = (theme: GrafanaTheme) => {
const getContextMenuStyles = stylesFactory((theme: GrafanaTheme) => {
const linkColor = selectThemeVariant(
{
light: theme.colors.dark2,
@@ -106,6 +107,7 @@ const getContextMenuStyles = (theme: GrafanaTheme) => {
z-index: 1;
box-shadow: 0 2px 5px 0 ${wrapperShadow};
min-width: 200px;
display: inline-block;
border-radius: ${theme.border.radius.sm};
`,
link: css`
@@ -146,11 +148,31 @@ const getContextMenuStyles = (theme: GrafanaTheme) => {
top: 4px;
`,
};
};
});
export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClose, items, renderHeader }) => {
const theme = useContext(ThemeContext);
const menuRef = useRef(null);
const menuRef = useRef<HTMLDivElement>(null);
const [positionStyles, setPositionStyles] = useState({});
useLayoutEffect(() => {
const menuElement = menuRef.current;
if (menuElement) {
const rect = menuElement.getBoundingClientRect();
const OFFSET = 5;
const collisions = {
right: window.innerWidth < x + rect.width,
bottom: window.innerHeight < rect.bottom + rect.height + OFFSET,
};
setPositionStyles({
position: 'fixed',
left: collisions.right ? x - rect.width - OFFSET : x - OFFSET,
top: collisions.bottom ? y - rect.height - OFFSET : y + OFFSET,
});
}
}, [menuRef.current]);
useClickAway(menuRef, () => {
if (onClose) {
onClose();
@@ -158,18 +180,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClo
});
const styles = getContextMenuStyles(theme);
return (
<Portal>
<div
ref={menuRef}
style={{
position: 'fixed',
left: x - 5,
top: y + 5,
}}
className={styles.wrapper}
>
<div ref={menuRef} style={positionStyles} className={styles.wrapper}>
{renderHeader && <div className={styles.header}>{renderHeader()}</div>}
<List
items={items || []}

View File

@@ -3,8 +3,9 @@ import { DataLink } from '@grafana/data';
import { FormField, Switch } from '../index';
import { VariableSuggestion } from './DataLinkSuggestions';
import { css } from 'emotion';
import { ThemeContext } from '../../themes/index';
import { ThemeContext, stylesFactory } from '../../themes/index';
import { DataLinkInput } from './DataLinkInput';
import { GrafanaTheme } from '../../types';
interface DataLinkEditorProps {
index: number;
@@ -15,9 +16,21 @@ interface DataLinkEditorProps {
onRemove: (link: DataLink) => void;
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
listItem: css`
margin-bottom: ${theme.spacing.sm};
`,
infoText: css`
padding-bottom: ${theme.spacing.md};
margin-left: 66px;
color: ${theme.colors.textWeak};
`,
}));
export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
({ index, value, onChange, onRemove, suggestions, isLast }) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
const [title, setTitle] = useState(value.title);
const onUrlChange = (url: string, callback?: () => void) => {
@@ -39,18 +52,8 @@ export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
onChange(index, { ...value, targetBlank: !value.targetBlank });
};
const listItemStyle = css`
margin-bottom: ${theme.spacing.sm};
`;
const infoTextStyle = css`
padding-bottom: ${theme.spacing.md};
margin-left: 66px;
color: ${theme.colors.textWeak};
`;
return (
<div className={listItemStyle}>
<div className={styles.listItem}>
<div className="gf-form gf-form--inline">
<FormField
className="gf-form--grow"
@@ -76,7 +79,7 @@ export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
`}
/>
{isLast && (
<div className={infoTextStyle}>
<div className={styles.infoText}>
With data links you can reference data variables like series name, labels and values. Type CMD+Space,
CTRL+Space, or $ to open variable suggestions.
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useCallback, useContext, useRef, RefObject } from 'react';
import React, { useState, useMemo, useContext, useRef, RefObject, memo } from 'react';
import { VariableSuggestion, VariableOrigin, DataLinkSuggestions } from './DataLinkSuggestions';
import { ThemeContext, DataLinkBuiltInVars, makeValue } from '../../index';
import { SelectionReference } from './SelectionReference';
@@ -12,6 +12,8 @@ import { css, cx } from 'emotion';
import { SlatePrism } from '../../slate-plugins';
import { SCHEMA } from '../../utils/slate';
import { stylesFactory } from '../../themes';
import { GrafanaTheme } from '../../types';
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
@@ -28,26 +30,27 @@ const plugins = [
}),
];
export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, suggestions }) => {
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
editor: css`
.token.builtInVariable {
color: ${theme.colors.queryGreen};
}
.token.variable {
color: ${theme.colors.queryKeyword};
}
`,
}));
// This memoised also because rerendering the slate editor grabs focus which created problem in some cases this
// was used and changes to different state were propagated here.
export const DataLinkInput: React.FC<DataLinkInputProps> = memo(({ value, onChange, suggestions }) => {
const editorRef = useRef<Editor>() as RefObject<Editor>;
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
const [showingSuggestions, setShowingSuggestions] = useState(false);
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value));
const getStyles = useCallback(() => {
return {
editor: css`
.token.builtInVariable {
color: ${theme.colors.queryGreen};
}
.token.variable {
color: ${theme.colors.queryKeyword};
}
`,
};
}, [theme]);
// Workaround for https://github.com/ianstormtaylor/slate/issues/2927
const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange });
stateRef.current = { showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange };
@@ -90,6 +93,7 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
const onUrlBlur = React.useCallback((event: Event, editor: CoreEditor, next: () => any) => {
// Callback needed for blur to work correctly
stateRef.current.onChange(Plain.serialize(stateRef.current.linkUrl), () => {
// This needs to be called after state is updated.
editorRef.current!.blur();
});
}, []);
@@ -155,11 +159,11 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
onBlur={onUrlBlur}
onKeyDown={(event, _editor, next) => onKeyDown(event as KeyboardEvent, next)}
plugins={plugins}
className={getStyles().editor}
className={styles.editor}
/>
</div>
</div>
);
};
});
DataLinkInput.displayName = 'DataLinkInput';

View File

@@ -5,6 +5,7 @@ import React, { useRef, useContext, useMemo } from 'react';
import useClickAway from 'react-use/lib/useClickAway';
import { List } from '../index';
import tinycolor from 'tinycolor2';
import { stylesFactory } from '../../themes';
export enum VariableOrigin {
Series = 'series',
@@ -28,7 +29,7 @@ interface DataLinkSuggestionsProps {
onClose?: () => void;
}
const getStyles = (theme: GrafanaTheme) => {
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const wrapperBg = selectThemeVariant(
{
light: theme.colors.white,
@@ -129,7 +130,7 @@ const getStyles = (theme: GrafanaTheme) => {
color: ${itemDocsColor};
`,
};
};
});
export const DataLinkSuggestions: React.FC<DataLinkSuggestionsProps> = ({ suggestions, ...otherProps }) => {
const ref = useRef(null);

View File

@@ -1,6 +1,7 @@
import React, { PureComponent, ReactNode } from 'react';
import { Alert } from '../Alert/Alert';
import { css } from 'emotion';
import { stylesFactory } from '../../themes';
interface ErrorInfo {
componentStack: string;
@@ -44,12 +45,12 @@ export class ErrorBoundary extends PureComponent<Props, State> {
}
}
function getAlertPageStyle() {
const getStyles = stylesFactory(() => {
return css`
width: 500px;
margin: 64px auto;
`;
}
});
interface WithAlertBoxProps {
title?: string;
@@ -85,7 +86,7 @@ export class ErrorBoundaryAlert extends PureComponent<WithAlertBoxProps> {
);
} else {
return (
<div className={getAlertPageStyle()}>
<div className={getStyles()}>
<h2>{title}</h2>
<details style={{ whiteSpace: 'pre-wrap' }}>
{error && error.toString()}

View File

@@ -5,6 +5,8 @@ import { LegendItem } from '../Legend/Legend';
import { SeriesColorChangeHandler } from './GraphWithLegend';
import { LegendStatsList } from '../Legend/LegendStatsList';
import { ThemeContext } from '../../themes/ThemeContext';
import { stylesFactory } from '../../themes';
import { GrafanaTheme } from '../../types';
export interface GraphLegendItemProps {
key?: React.Key;
@@ -56,6 +58,32 @@ export const GraphLegendListItem: React.FunctionComponent<GraphLegendItemProps>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
row: css`
font-size: ${theme.typography.size.sm};
td {
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
white-space: nowrap;
}
`,
label: css`
cursor: pointer;
white-space: nowrap;
`,
itemWrapper: css`
display: flex;
white-space: nowrap;
`,
value: css`
text-align: right;
`,
yAxisLabel: css`
color: ${theme.colors.gray2};
`,
};
});
export const GraphLegendTableRow: React.FunctionComponent<GraphLegendItemProps> = ({
item,
onSeriesColorChange,
@@ -64,27 +92,11 @@ export const GraphLegendTableRow: React.FunctionComponent<GraphLegendItemProps>
className,
}) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
return (
<tr
className={cx(
css`
font-size: ${theme.typography.size.sm};
td {
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
white-space: nowrap;
}
`,
className
)}
>
<tr className={cx(styles.row, className)}>
<td>
<span
className={css`
display: flex;
white-space: nowrap;
`}
>
<span className={styles.itemWrapper}>
<LegendSeriesIcon
disabled={!!onSeriesColorChange}
color={item.color}
@@ -102,33 +114,16 @@ export const GraphLegendTableRow: React.FunctionComponent<GraphLegendItemProps>
onLabelClick(item, event);
}
}}
className={css`
cursor: pointer;
white-space: nowrap;
`}
className={styles.label}
>
{item.label}{' '}
{item.yAxis === 2 && (
<span
className={css`
color: ${theme.colors.gray2};
`}
>
(right y-axis)
</span>
)}
{item.label} {item.yAxis === 2 && <span className={styles.yAxisLabel}>(right y-axis)</span>}
</div>
</span>
</td>
{item.displayValues &&
item.displayValues.map((stat, index) => {
return (
<td
className={css`
text-align: right;
`}
key={`${stat.title}-${index}`}
>
<td className={styles.value} key={`${stat.title}-${index}`}>
{stat.text}
</td>
);

View File

@@ -8,6 +8,7 @@ import { Graph, GraphProps } from './Graph';
import { LegendRenderOptions, LegendItem, LegendDisplayMode } from '../Legend/Legend';
import { GraphLegend } from './GraphLegend';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { stylesFactory } from '../../themes';
export type SeriesOptionChangeHandler<TOption> = (label: string, option: TOption) => void;
export type SeriesColorChangeHandler = SeriesOptionChangeHandler<string>;
@@ -24,7 +25,7 @@ export interface GraphWithLegendProps extends GraphProps, LegendRenderOptions {
onToggleSort: (sortBy: string) => void;
}
const getGraphWithLegendStyles = ({ placement }: GraphWithLegendProps) => ({
const getGraphWithLegendStyles = stylesFactory(({ placement }: GraphWithLegendProps) => ({
wrapper: css`
display: flex;
flex-direction: ${placement === 'under' ? 'column' : 'row'};
@@ -38,7 +39,7 @@ const getGraphWithLegendStyles = ({ placement }: GraphWithLegendProps) => ({
padding: 10px 0;
max-height: ${placement === 'under' ? '35%' : 'none'};
`,
});
}));
const shouldHideLegendItem = (data: GraphSeriesValue[][], hideEmpty = false, hideZero = false) => {
const isZeroOnlySeries = data.reduce((acc, current) => acc + (current[1] || 0), 0) === 0;

View File

@@ -4,6 +4,30 @@ import { InlineList } from '../List/InlineList';
import { List } from '../List/List';
import { css, cx } from 'emotion';
import { ThemeContext } from '../../themes/ThemeContext';
import { stylesFactory } from '../../themes';
import { GrafanaTheme } from '../../types';
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
item: css`
padding-left: 10px;
display: flex;
font-size: ${theme.typography.size.sm};
white-space: nowrap;
`,
wrapper: css`
display: flex;
flex-wrap: wrap;
justify-content: space-between;
width: 100%;
`,
section: css`
display: flex;
`,
sectionRight: css`
justify-content: flex-end;
flex-grow: 1;
`,
}));
export const LegendList: React.FunctionComponent<LegendComponentProps> = ({
items,
@@ -12,45 +36,16 @@ export const LegendList: React.FunctionComponent<LegendComponentProps> = ({
className,
}) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
const renderItem = (item: LegendItem, index: number) => {
return (
<span
className={css`
padding-left: 10px;
display: flex;
font-size: ${theme.typography.size.sm};
white-space: nowrap;
`}
>
{itemRenderer ? itemRenderer(item, index) : item.label}
</span>
);
return <span className={styles.item}>{itemRenderer ? itemRenderer(item, index) : item.label}</span>;
};
const getItemKey = (item: LegendItem) => `${item.label}`;
const styles = {
wrapper: cx(
css`
display: flex;
flex-wrap: wrap;
justify-content: space-between;
width: 100%;
`,
className
),
section: css`
display: flex;
`,
sectionRight: css`
justify-content: flex-end;
flex-grow: 1;
`,
};
return placement === 'under' ? (
<div className={styles.wrapper}>
<div className={cx(styles.wrapper, className)}>
<div className={styles.section}>
<InlineList items={items.filter(item => item.yAxis === 1)} renderItem={renderItem} getItemKey={getItemKey} />
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { cx, css } from 'emotion';
import { stylesFactory } from '../../themes';
export interface ListProps<T> {
items: T[];
@@ -12,32 +13,27 @@ interface AbstractListProps<T> extends ListProps<T> {
inline?: boolean;
}
const getStyles = stylesFactory((inlineList = false) => ({
list: css`
list-style-type: none;
margin: 0;
padding: 0;
`,
item: css`
display: ${(inlineList && 'inline-block') || 'block'};
`,
}));
export class AbstractList<T> extends React.PureComponent<AbstractListProps<T>> {
constructor(props: AbstractListProps<T>) {
super(props);
this.getListStyles = this.getListStyles.bind(this);
}
getListStyles() {
const { inline, className } = this.props;
return {
list: cx([
css`
list-style-type: none;
margin: 0;
padding: 0;
`,
className,
]),
item: css`
display: ${(inline && 'inline-block') || 'block'};
`,
};
}
render() {
const { items, renderItem, getItemKey, className } = this.props;
const styles = this.getListStyles();
const { items, renderItem, getItemKey, className, inline } = this.props;
const styles = getStyles(inline);
return (
<ul className={cx(styles.list, className)}>
{items.map((item, i) => {

View File

@@ -2,10 +2,10 @@
exports[`AbstractList allows custom item key 1`] = `
<ul
className="css-9xf0yn"
className="css-1ld8h5b"
>
<li
className="css-rwbibe"
className="css-8qpjjf"
key="item1"
>
<div>
@@ -18,7 +18,7 @@ exports[`AbstractList allows custom item key 1`] = `
</div>
</li>
<li
className="css-rwbibe"
className="css-8qpjjf"
key="item2"
>
<div>
@@ -31,7 +31,7 @@ exports[`AbstractList allows custom item key 1`] = `
</div>
</li>
<li
className="css-rwbibe"
className="css-8qpjjf"
key="item3"
>
<div>
@@ -48,10 +48,10 @@ exports[`AbstractList allows custom item key 1`] = `
exports[`AbstractList renders items using renderItem prop function 1`] = `
<ul
className="css-9xf0yn"
className="css-1ld8h5b"
>
<li
className="css-rwbibe"
className="css-8qpjjf"
key="0"
>
<div>
@@ -64,7 +64,7 @@ exports[`AbstractList renders items using renderItem prop function 1`] = `
</div>
</li>
<li
className="css-rwbibe"
className="css-8qpjjf"
key="1"
>
<div>
@@ -77,7 +77,7 @@ exports[`AbstractList renders items using renderItem prop function 1`] = `
</div>
</li>
<li
className="css-rwbibe"
className="css-8qpjjf"
key="2"
>
<div>

View File

@@ -1,5 +1,5 @@
import { ThemeContext, withTheme, useTheme } from './ThemeContext';
import { getTheme, mockTheme } from './getTheme';
import { selectThemeVariant } from './selectThemeVariant';
export { stylesFactory } from './stylesFactory';
export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant, useTheme };

View File

@@ -0,0 +1,31 @@
import { stylesFactory } from './stylesFactory';
interface FakeProps {
theme: {
a: string;
};
}
describe('Stylesheet creation', () => {
it('memoizes results', () => {
const spy = jest.fn();
const getStyles = stylesFactory(({ theme }: FakeProps) => {
spy();
return {
className: `someClass${theme.a}`,
};
});
const props: FakeProps = { theme: { a: '-interpolated' } };
const changedProps: FakeProps = { theme: { a: '-interpolatedChanged' } };
const styles = getStyles(props);
getStyles(props);
expect(spy).toBeCalledTimes(1);
expect(styles.className).toBe('someClass-interpolated');
const styles2 = getStyles(changedProps);
expect(spy).toBeCalledTimes(2);
expect(styles2.className).toBe('someClass-interpolatedChanged');
});
});

View File

@@ -0,0 +1,12 @@
import memoizeOne from 'memoize-one';
// import { KeyValue } from '@grafana/data';
/**
* Creates memoized version of styles creator
* @param stylesCreator function accepting dependencies based on which styles are created
*/
export function stylesFactory<ResultFn extends (this: any, ...newArgs: any[]) => ReturnType<ResultFn>>(
stylesCreator: ResultFn
) {
return memoizeOne(stylesCreator);
}

View File

@@ -37,7 +37,11 @@ export interface PanelProps<T = any> {
export interface PanelEditorProps<T = any> {
options: T;
onOptionsChange: (options: T) => void;
onOptionsChange: (
options: T,
// callback can be used to run something right after update.
callback?: () => void
) => void;
data: PanelData;
}

View File

@@ -37,6 +37,7 @@ type notificationService struct {
func (n *notificationService) SendIfNeeded(context *EvalContext) error {
notifierStates, err := n.getNeededNotifiers(context.Rule.OrgID, context.Rule.Notifications, context)
if err != nil {
n.log.Error("Failed to get alert notifiers", "error", err)
return err
}
@@ -109,10 +110,11 @@ func (n *notificationService) sendNotifications(evalContext *EvalContext, notifi
err := n.sendNotification(evalContext, notifierState)
if err != nil {
n.log.Error("failed to send notification", "uid", notifierState.notifier.GetNotifierUID(), "error", err)
return err
if evalContext.IsTestRun {
return err
}
}
}
return nil
}

View File

@@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { Tooltip, PanelPlugin, PanelPluginMeta } from '@grafana/ui';
import { PanelPlugin, PanelPluginMeta, Tooltip } from '@grafana/ui';
import { AngularComponent, config } from '@grafana/runtime';
import { QueriesTab } from './QueriesTab';
@@ -12,8 +12,9 @@ import { AlertTab } from '../../alerting/AlertTab';
import { PanelModel } from '../state/PanelModel';
import { DashboardModel } from '../state/DashboardModel';
import { StoreState } from '../../../types';
import { PanelEditorTabIds, PanelEditorTab } from './state/reducers';
import { refreshPanelEditor, changePanelEditorTab, panelEditorCleanUp } from './state/actions';
import { PanelEditorTab, PanelEditorTabIds } from './state/reducers';
import { changePanelEditorTab, panelEditorCleanUp, refreshPanelEditor } from './state/actions';
import { getActiveTabAndTabs } from './state/selectors';
interface PanelEditorProps {
panel: PanelModel;
@@ -108,10 +109,7 @@ class UnConnectedPanelEditor extends PureComponent<PanelEditorProps> {
}
}
export const mapStateToProps = (state: StoreState) => ({
activeTab: state.location.query.tab || PanelEditorTabIds.Queries,
tabs: state.panelEditor.tabs,
});
export const mapStateToProps = (state: StoreState) => getActiveTabAndTabs(state.location, state.panelEditor);
const mapDispatchToProps = { refreshPanelEditor, panelEditorCleanUp, changePanelEditorTab };

View File

@@ -165,9 +165,9 @@ export class VisualizationTab extends PureComponent<Props, State> {
this.setState({ searchQuery: '' });
};
onPanelOptionsChanged = (options: any) => {
onPanelOptionsChanged = (options: any, callback?: () => void) => {
this.props.panel.updateOptions(options);
this.forceUpdate();
this.forceUpdate(callback);
};
onOpenVizPicker = () => {

View File

@@ -0,0 +1,88 @@
import { getActiveTabAndTabs } from './selectors';
import { LocationState } from '../../../../types';
import { getPanelEditorTab, PanelEditorState, PanelEditorTab, PanelEditorTabIds } from './reducers';
describe('getActiveTabAndTabs', () => {
describe('when called and location state contains tab', () => {
it('then it should return location state', () => {
const activeTabId = 1337;
const location: LocationState = {
path: 'a path',
lastUpdated: 1,
replace: false,
routeParams: {},
query: {
tab: activeTabId,
},
url: 'an url',
};
const panelEditor: PanelEditorState = {
activeTab: PanelEditorTabIds.Queries,
tabs: [],
};
const result = getActiveTabAndTabs(location, panelEditor);
expect(result).toEqual({
activeTab: activeTabId,
tabs: [],
});
});
});
describe('when called without location state and PanelEditor state contains tabs', () => {
it('then it should return the id for the first tab in PanelEditor state', () => {
const activeTabId = PanelEditorTabIds.Visualization;
const tabs = [getPanelEditorTab(PanelEditorTabIds.Visualization), getPanelEditorTab(PanelEditorTabIds.Advanced)];
const location: LocationState = {
path: 'a path',
lastUpdated: 1,
replace: false,
routeParams: {},
query: {
tab: undefined,
},
url: 'an url',
};
const panelEditor: PanelEditorState = {
activeTab: PanelEditorTabIds.Advanced,
tabs,
};
const result = getActiveTabAndTabs(location, panelEditor);
expect(result).toEqual({
activeTab: activeTabId,
tabs,
});
});
});
describe('when called without location state and PanelEditor state does not contain tabs', () => {
it('then it should return PanelEditorTabIds.Queries', () => {
const activeTabId = PanelEditorTabIds.Queries;
const tabs: PanelEditorTab[] = [];
const location: LocationState = {
path: 'a path',
lastUpdated: 1,
replace: false,
routeParams: {},
query: {
tab: undefined,
},
url: 'an url',
};
const panelEditor: PanelEditorState = {
activeTab: PanelEditorTabIds.Advanced,
tabs,
};
const result = getActiveTabAndTabs(location, panelEditor);
expect(result).toEqual({
activeTab: activeTabId,
tabs,
});
});
});
});

View File

@@ -0,0 +1,11 @@
import memoizeOne from 'memoize-one';
import { LocationState } from '../../../../types';
import { PanelEditorState, PanelEditorTabIds } from './reducers';
export const getActiveTabAndTabs = memoizeOne((location: LocationState, panelEditor: PanelEditorState) => {
const panelEditorTab = panelEditor.tabs.length > 0 ? panelEditor.tabs[0].id : PanelEditorTabIds.Queries;
return {
activeTab: location.query.tab || panelEditorTab,
tabs: panelEditor.tabs,
};
});

View File

@@ -4,17 +4,17 @@ import { QueryField } from './QueryField';
describe('<QueryField />', () => {
it('should render with null initial value', () => {
const wrapper = shallow(<QueryField initialQuery={null} />);
const wrapper = shallow(<QueryField query={null} />);
expect(wrapper.find('div').exists()).toBeTruthy();
});
it('should render with empty initial value', () => {
const wrapper = shallow(<QueryField initialQuery="" />);
const wrapper = shallow(<QueryField query="" />);
expect(wrapper.find('div').exists()).toBeTruthy();
});
it('should render with initial value', () => {
const wrapper = shallow(<QueryField initialQuery="my query" />);
const wrapper = shallow(<QueryField query="my query" />);
expect(wrapper.find('div').exists()).toBeTruthy();
});
});

View File

@@ -15,18 +15,18 @@ import IndentationPlugin from './slate-plugins/indentation';
import ClipboardPlugin from './slate-plugins/clipboard';
import RunnerPlugin from './slate-plugins/runner';
import SuggestionsPlugin, { SuggestionsState } from './slate-plugins/suggestions';
import { Typeahead } from './Typeahead';
import { makeValue, SCHEMA } from '@grafana/ui';
export const HIGHLIGHT_WAIT = 500;
export interface QueryFieldProps {
additionalPlugins?: Plugin[];
cleanText?: (text: string) => string;
disabled?: boolean;
initialQuery: string | null;
// We have both value and local state. This is usually an antipattern but we need to keep local state
// for perf reasons and also have outside value in for example in Explore redux that is mutable from logs
// creating a two way binding.
query: string | null;
onRunQuery?: () => void;
onChange?: (value: string) => void;
onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
@@ -43,7 +43,6 @@ export interface QueryFieldState {
typeaheadPrefix: string;
typeaheadText: string;
value: Value;
lastExecutedValue: Value;
}
export interface TypeaheadInput {
@@ -62,18 +61,19 @@ export interface TypeaheadInput {
* Implement props.onTypeahead to use suggestions, see PromQueryField.tsx as an example.
*/
export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
menuEl: HTMLElement | null;
plugins: Plugin[];
resetTimer: NodeJS.Timer;
mounted: boolean;
updateHighlightsTimer: Function;
runOnChangeDebounced: Function;
editor: Editor;
// Is required by SuggestionsPlugin
typeaheadRef: Typeahead;
lastExecutedValue: Value | null = null;
constructor(props: QueryFieldProps, context: Context<any>) {
super(props, context);
this.updateHighlightsTimer = _.debounce(this.updateLogsHighlights, HIGHLIGHT_WAIT);
this.runOnChangeDebounced = _.debounce(this.runOnChange, 500);
const { onTypeahead, cleanText, portalOrigin, onWillApplySuggestion } = props;
@@ -82,7 +82,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
NewlinePlugin(),
SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion, component: this }),
ClearPlugin(),
RunnerPlugin({ handler: this.executeOnChangeAndRunQueries }),
RunnerPlugin({ handler: this.runOnChangeAndRunQuery }),
SelectionShortcutsPlugin(),
IndentationPlugin(),
ClipboardPlugin(),
@@ -94,8 +94,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
typeaheadContext: null,
typeaheadPrefix: '',
typeaheadText: '',
value: makeValue(props.initialQuery || '', props.syntax),
lastExecutedValue: null,
value: makeValue(props.query || '', props.syntax),
};
}
@@ -109,14 +108,15 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
}
componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
const { initialQuery, syntax } = this.props;
const { query, syntax } = this.props;
const { value } = this.state;
// Handle two way binging between local state and outside prop.
// if query changed from the outside
if (initialQuery !== prevProps.initialQuery) {
if (query !== prevProps.query) {
// and we have a version that differs
if (initialQuery !== Plain.serialize(value)) {
this.setState({ value: makeValue(initialQuery || '', syntax) });
if (query !== Plain.serialize(value)) {
this.setState({ value: makeValue(query || '', syntax) });
}
}
}
@@ -129,25 +129,31 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
}
}
onChange = (value: Value, invokeParentOnValueChanged?: boolean) => {
/**
* Update local state, propagate change upstream and optionally run the query afterwards.
*/
onChange = (value: Value, runQuery?: boolean) => {
const documentChanged = value.document !== this.state.value.document;
const prevValue = this.state.value;
// Control editor loop, then pass text change up to parent
// Update local state with new value and optionally change value upstream.
this.setState({ value }, () => {
// The diff is needed because the actual value of editor have much more metadata (for example text selection)
// that is not passed upstream so every change of editor value does not mean change of the query text.
if (documentChanged) {
const textChanged = Plain.serialize(prevValue) !== Plain.serialize(value);
if (textChanged && invokeParentOnValueChanged) {
this.executeOnChangeAndRunQueries();
if (textChanged && runQuery) {
this.runOnChangeAndRunQuery();
}
if (textChanged && !invokeParentOnValueChanged) {
this.updateHighlightsTimer();
if (textChanged && !runQuery) {
// Debounce change propagation by default for perf reasons.
this.runOnChangeDebounced();
}
}
});
};
updateLogsHighlights = () => {
runOnChange = () => {
const { onChange } = this.props;
if (onChange) {
@@ -155,30 +161,32 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
}
};
executeOnChangeAndRunQueries = () => {
// Send text change to parent
const { onChange, onRunQuery } = this.props;
if (onChange) {
onChange(Plain.serialize(this.state.value));
}
runOnRunQuery = () => {
const { onRunQuery } = this.props;
if (onRunQuery) {
onRunQuery();
this.setState({ lastExecutedValue: this.state.value });
this.lastExecutedValue = this.state.value;
}
};
runOnChangeAndRunQuery = () => {
// onRunQuery executes query from Redux in Explore so it needs to be updated sync in case we want to run
// the query.
this.runOnChange();
this.runOnRunQuery();
};
/**
* We need to handle blur events here mainly because of dashboard panels which expect to have query executed on blur.
*/
handleBlur = (event: Event, editor: CoreEditor, next: Function) => {
const { lastExecutedValue } = this.state;
const previousValue = lastExecutedValue ? Plain.serialize(this.state.lastExecutedValue) : null;
const previousValue = this.lastExecutedValue ? Plain.serialize(this.lastExecutedValue) : null;
const currentValue = Plain.serialize(editor.value);
if (previousValue !== currentValue) {
this.executeOnChangeAndRunQueries();
this.runOnChangeAndRunQuery();
}
editor.blur();
return next();
};

View File

@@ -71,7 +71,7 @@ class ElasticsearchQueryField extends React.PureComponent<Props, State> {
<div className="gf-form gf-form--grow flex-shrink-1">
<QueryField
additionalPlugins={this.plugins}
initialQuery={query.query}
query={query.query}
onChange={this.onChangeQuery}
onRunQuery={this.props.onRunQuery}
placeholder="Enter a Lucene query"

View File

@@ -215,7 +215,7 @@ class QueryField extends React.Component<any, any> {
);
};
handleBlur = () => {
handleBlur = (event: Event, editor: CoreEditor, next: Function) => {
const { onBlur } = this.props;
// If we dont wait here, menu clicks wont work because the menu
// will be gone.
@@ -224,15 +224,17 @@ class QueryField extends React.Component<any, any> {
onBlur();
}
this.restoreEscapeKeyBinding();
return next();
};
handleFocus = () => {
handleFocus = (event: Event, editor: CoreEditor, next: Function) => {
const { onFocus } = this.props;
if (onFocus) {
onFocus();
}
// Don't go back to dashboard if Escape pressed inside the editor.
this.removeEscapeKeyBinding();
return next();
};
removeEscapeKeyBinding() {

View File

@@ -184,7 +184,7 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
<QueryField
additionalPlugins={this.plugins}
cleanText={cleanText}
initialQuery={query.expr}
query={query.expr}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onChange={this.onChangeQuery}

View File

@@ -314,7 +314,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
<QueryField
additionalPlugins={this.plugins}
cleanText={cleanText}
initialQuery={query.expr}
query={query.expr}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onChange={this.onChangeQuery}

View File

@@ -48,24 +48,39 @@ export class GaugePanelEditor extends PureComponent<PanelEditorProps<GaugeOption
});
};
onDisplayOptionsChanged = (fieldOptions: FieldDisplayOptions) =>
this.props.onOptionsChange({
...this.props.options,
fieldOptions,
});
onDisplayOptionsChanged = (
fieldOptions: FieldDisplayOptions,
event?: React.SyntheticEvent<HTMLElement>,
callback?: () => void
) =>
this.props.onOptionsChange(
{
...this.props.options,
fieldOptions,
},
callback
);
onDefaultsChange = (field: FieldConfig) => {
this.onDisplayOptionsChanged({
...this.props.options.fieldOptions,
defaults: field,
});
onDefaultsChange = (field: FieldConfig, event?: React.SyntheticEvent<HTMLElement>, callback?: () => void) => {
this.onDisplayOptionsChanged(
{
...this.props.options.fieldOptions,
defaults: field,
},
event,
callback
);
};
onDataLinksChanged = (links: DataLink[]) => {
this.onDefaultsChange({
...this.props.options.fieldOptions.defaults,
links,
});
onDataLinksChanged = (links: DataLink[], callback?: () => void) => {
this.onDefaultsChange(
{
...this.props.options.fieldOptions.defaults,
links,
},
undefined,
callback
);
};
render() {

84
style_guides/styling.md Normal file
View File

@@ -0,0 +1,84 @@
# Styling Grafana
## Emotion
[Emotion](https://emotion.sh/docs/introduction) is our default-to-be approach to styling React components. It provides a way for styles to be a consequence of properties and state of a component.
### Usage
#### Basic styling
For styling components use Emotion's `css` function
```tsx
import { css } from 'emotion';
const ComponentA = () => {
return (
<div className={css`background: red;`}>
As red as you can ge
</div>
);
}
```
#### Styling complex components
In more complex cases, especially when you need to style multiple DOM elements in one component or when your styles that depend on properties and/or state, you should create a helper function that returns an object with desired stylesheet. Let's say you need to style a component that has different background depending on the theme:
```tsx
import { css, cx } from 'emotion';
import { GrafanaTheme, useTheme, selectThemeVariant } from '@grafana/ui';
const getStyles = (theme: GrafanaTheme) => {
const backgroundColor = selectThemeVariant({ light: theme.colors.red, dark: theme.colors.blue }, theme.type);
return {
wrapper: css`
background: ${backgroundColor};
`,
icon: css`font-size:${theme.typography.size.sm}`;
};
}
const ComponentA = () => {
const theme = useTheme();
const styles = getStyles(theme);
return (
<div className={styles.wrapper}>
As red as you can ge
<i className={styles.icon} /\>
</div>
);
}
```
For more information about themes at Grafana please see [themes guide](./themes.md)
#### Composing class names
For class composition use Emotion's `cx` function
```tsx
import { css, cx } from 'emotion';
interface Props {
className?: string;
}
const ComponentA: React.FC<Props> = ({ className }) => {
const finalClassName = cx(
className,
css`background: red`,
)
return (
<div className={finalClassName}>
As red as you can ge
</div>
);
}
```