Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a2bfb7e38 | ||
|
|
80431256ca | ||
|
|
af364fb91d | ||
|
|
90fe6c5a9f | ||
|
|
399dd583da | ||
|
|
475a0baee1 | ||
|
|
f3e7f878d6 | ||
|
|
a364a86855 | ||
|
|
223f30c71f | ||
|
|
f53c6ebb65 | ||
|
|
bcbe45a745 | ||
|
|
1e32d7bced | ||
|
|
fed38ea617 | ||
|
|
e40dd982c6 |
@@ -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)
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"packages": ["packages/*"],
|
||||
"version": "6.4.2"
|
||||
"version": "6.4.3"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
11
packages/grafana-toolkit/src/config/utils/getPluginId.ts
Normal file
11
packages/grafana-toolkit/src/config/utils/getPluginId.ts
Normal 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;
|
||||
};
|
||||
@@ -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]',
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || []}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
31
packages/grafana-ui/src/themes/stylesFactory.test.ts
Normal file
31
packages/grafana-ui/src/themes/stylesFactory.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
12
packages/grafana-ui/src/themes/stylesFactory.ts
Normal file
12
packages/grafana-ui/src/themes/stylesFactory.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
84
style_guides/styling.md
Normal 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>
|
||||
);
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user