Transformations: UI tweaks, filter by name regex validation (#23800)

* Add validation to filter by name regex, minor layout tweaks

* Use cards uin for non configured transformations
This commit is contained in:
Dominik Prokop
2020-04-23 10:12:06 +02:00
committed by GitHub
parent 4b42697912
commit fe28e2a6b1
8 changed files with 198 additions and 63 deletions

View File

@@ -11,7 +11,12 @@ const fieldNameMacher: FieldMatcherInfo<string> = {
defaultOptions: '/.*/', defaultOptions: '/.*/',
get: (pattern: string) => { get: (pattern: string) => {
const regex = stringToJsRegex(pattern); let regex = new RegExp('');
try {
regex = stringToJsRegex(pattern);
} catch (e) {
console.error(e);
}
return (field: Field) => { return (field: Field) => {
return regex.test(field.name); return regex.test(field.name);
}; };

View File

@@ -7,7 +7,7 @@ enum Orientation {
Horizontal, Horizontal,
Vertical, Vertical,
} }
type Spacing = 'xs' | 'sm' | 'md' | 'lg'; type Spacing = 'none' | 'xs' | 'sm' | 'md' | 'lg';
type Justify = 'flex-start' | 'flex-end' | 'space-between' | 'center'; type Justify = 'flex-start' | 'flex-end' | 'space-between' | 'center';
type Align = 'normal' | 'flex-start' | 'flex-end' | 'center'; type Align = 'normal' | 'flex-start' | 'flex-end' | 'center';
@@ -18,6 +18,7 @@ export interface LayoutProps {
justify?: Justify; justify?: Justify;
align?: Align; align?: Align;
width?: string; width?: string;
wrap?: boolean;
} }
export interface ContainerProps { export interface ContainerProps {
@@ -31,10 +32,11 @@ export const Layout: React.FC<LayoutProps> = ({
spacing = 'sm', spacing = 'sm',
justify = 'flex-start', justify = 'flex-start',
align = 'normal', align = 'normal',
wrap = false,
width = 'auto', width = 'auto',
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme, orientation, spacing, justify, align); const styles = getStyles(theme, orientation, spacing, justify, align, wrap);
return ( return (
<div className={styles.layout} style={{ width }}> <div className={styles.layout} style={{ width }}>
{React.Children.toArray(children) {React.Children.toArray(children)
@@ -55,13 +57,26 @@ export const HorizontalGroup: React.FC<Omit<LayoutProps, 'orientation'>> = ({
spacing, spacing,
justify, justify,
align = 'center', align = 'center',
wrap,
width, width,
}) => ( }) => (
<Layout spacing={spacing} justify={justify} orientation={Orientation.Horizontal} align={align} width={width}> <Layout
spacing={spacing}
justify={justify}
orientation={Orientation.Horizontal}
align={align}
width={width}
wrap={wrap}
>
{children} {children}
</Layout> </Layout>
); );
export const VerticalGroup: React.FC<Omit<LayoutProps, 'orientation'>> = ({ children, spacing, justify, width }) => ( export const VerticalGroup: React.FC<Omit<LayoutProps, 'orientation' | 'wrap'>> = ({
children,
spacing,
justify,
width,
}) => (
<Layout spacing={spacing} justify={justify} orientation={Orientation.Vertical} width={width}> <Layout spacing={spacing} justify={justify} orientation={Orientation.Vertical} width={width}>
{children} {children}
</Layout> </Layout>
@@ -74,22 +89,28 @@ export const Container: React.FC<ContainerProps> = ({ children, padding, margin
}; };
const getStyles = stylesFactory( const getStyles = stylesFactory(
(theme: GrafanaTheme, orientation: Orientation, spacing: Spacing, justify: Justify, align) => { (theme: GrafanaTheme, orientation: Orientation, spacing: Spacing, justify: Justify, align, wrap) => {
const finalSpacing = spacing !== 'none' ? theme.spacing[spacing] : 0;
const marginCompensation = orientation === Orientation.Horizontal && !wrap ? 0 : `-${finalSpacing}`;
return { return {
layout: css` layout: css`
display: flex; display: flex;
flex-direction: ${orientation === Orientation.Vertical ? 'column' : 'row'}; flex-direction: ${orientation === Orientation.Vertical ? 'column' : 'row'};
flex-wrap: ${wrap ? 'wrap' : 'nowrap'};
justify-content: ${justify}; justify-content: ${justify};
align-items: ${align}; align-items: ${align};
height: 100%; height: 100%;
max-width: 100%; max-width: 100%;
// compensate for last row margin when wrapped, horizontal layout
margin-bottom: ${marginCompensation};
`, `,
childWrapper: css` childWrapper: css`
margin-bottom: ${orientation === Orientation.Horizontal ? 0 : theme.spacing[spacing]}; margin-bottom: ${orientation === Orientation.Horizontal && !wrap ? 0 : finalSpacing};
margin-right: ${orientation === Orientation.Horizontal ? theme.spacing[spacing] : 0}; margin-right: ${orientation === Orientation.Horizontal ? finalSpacing : 0};
display: flex; display: flex;
align-items: ${align}; align-items: ${align};
height: 100%; // height: 100%;
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
@@ -101,8 +122,8 @@ const getStyles = stylesFactory(
); );
const getContainerStyles = stylesFactory((theme: GrafanaTheme, padding?: Spacing, margin?: Spacing) => { const getContainerStyles = stylesFactory((theme: GrafanaTheme, padding?: Spacing, margin?: Spacing) => {
const paddingSize = (padding && theme.spacing[padding]) || 0; const paddingSize = (padding && padding !== 'none' && theme.spacing[padding]) || 0;
const marginSize = (margin && theme.spacing[margin]) || 0; const marginSize = (margin && margin !== 'none' && theme.spacing[margin]) || 0;
return { return {
wrapper: css` wrapper: css`
margin: ${marginSize}; margin: ${marginSize};

View File

@@ -128,7 +128,7 @@ export class CalculateFieldTransformerEditor extends React.PureComponent<
<div className="gf-form-inline"> <div className="gf-form-inline">
<div className="gf-form gf-form--grow"> <div className="gf-form gf-form--grow">
<div className="gf-form-label width-8">Field name</div> <div className="gf-form-label width-8">Field name</div>
<HorizontalGroup spacing="xs"> <HorizontalGroup spacing="xs" align="flex-start" wrap>
{names.map((o, i) => { {names.map((o, i) => {
return ( return (
<FilterPill <FilterPill

View File

@@ -10,6 +10,8 @@ import {
import { HorizontalGroup } from '../Layout/Layout'; import { HorizontalGroup } from '../Layout/Layout';
import { Input } from '../Input/Input'; import { Input } from '../Input/Input';
import { FilterPill } from '../FilterPill/FilterPill'; import { FilterPill } from '../FilterPill/FilterPill';
import { Field } from '../Forms/Field';
import { css } from 'emotion';
interface FilterByNameTransformerEditorProps extends TransformerUIProps<FilterFieldsByNameTransformerOptions> {} interface FilterByNameTransformerEditorProps extends TransformerUIProps<FilterFieldsByNameTransformerOptions> {}
@@ -18,6 +20,7 @@ interface FilterByNameTransformerEditorState {
options: FieldNameInfo[]; options: FieldNameInfo[];
selected: string[]; selected: string[];
regex?: string; regex?: string;
isRegexValid?: boolean;
} }
interface FieldNameInfo { interface FieldNameInfo {
@@ -34,6 +37,7 @@ export class FilterByNameTransformerEditor extends React.PureComponent<
include: props.options.include || [], include: props.options.include || [],
options: [], options: [],
selected: [], selected: [],
isRegexValid: true,
}; };
} }
@@ -97,36 +101,69 @@ export class FilterByNameTransformerEditor extends React.PureComponent<
}; };
onChange = (selected: string[]) => { onChange = (selected: string[]) => {
const { regex, isRegexValid } = this.state;
let include = selected;
if (regex && isRegexValid) {
include = include.concat([regex]);
}
this.setState({ selected }, () => { this.setState({ selected }, () => {
this.props.onChange({ this.props.onChange({
...this.props.options, ...this.props.options,
include: this.state.regex ? [...selected, this.state.regex] : selected, include,
}); });
}); });
}; };
onInputBlur = (e: React.FocusEvent<HTMLInputElement>) => { onInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { selected, regex } = this.state; const { selected, regex } = this.state;
this.props.onChange({ let isRegexValid = true;
...this.props.options, try {
include: regex ? [...selected, regex] : selected, if (regex) {
new RegExp(regex);
}
} catch (e) {
isRegexValid = false;
}
if (isRegexValid) {
this.props.onChange({
...this.props.options,
include: regex ? [...selected, regex] : selected,
});
} else {
this.props.onChange({
...this.props.options,
include: selected,
});
}
this.setState({
isRegexValid,
}); });
}; };
render() { render() {
const { options, selected } = this.state; const { options, selected, isRegexValid } = this.state;
return ( return (
<div className="gf-form-inline"> <div className="gf-form-inline">
<div className="gf-form gf-form--grow"> <div className="gf-form gf-form--grow">
<div className="gf-form-label width-8">Field name</div> <div className="gf-form-label width-8">Field name</div>
<HorizontalGroup spacing="xs"> <HorizontalGroup spacing="xs" align="flex-start" wrap>
<Input <Field
placeholder="Regular expression pattern" invalid={!isRegexValid}
value={this.state.regex || ''} error={!isRegexValid ? 'Invalid pattern' : undefined}
onChange={e => this.setState({ regex: e.currentTarget.value })} className={css`
onBlur={this.onInputBlur} margin-bottom: 0;
width={25} `}
/> >
<Input
placeholder="Regular expression pattern"
value={this.state.regex || ''}
onChange={e => this.setState({ regex: e.currentTarget.value })}
onBlur={this.onInputBlur}
width={25}
/>
</Field>
{options.map((o, i) => { {options.map((o, i) => {
const label = `${o.name}${o.count > 1 ? ' (' + o.count + ')' : ''}`; const label = `${o.name}${o.count > 1 ? ' (' + o.count + ')' : ''}`;
const isSelected = selected.indexOf(o.name) > -1; const isSelected = selected.indexOf(o.name) > -1;

View File

@@ -101,7 +101,7 @@ export class FilterByRefIdTransformerEditor extends React.PureComponent<
<div className="gf-form-inline"> <div className="gf-form-inline">
<div className="gf-form gf-form--grow"> <div className="gf-form gf-form--grow">
<div className="gf-form-label width-8">Series refId</div> <div className="gf-form-label width-8">Series refId</div>
<HorizontalGroup spacing="xs"> <HorizontalGroup spacing="xs" align="flex-start" wrap>
{options.map((o, i) => { {options.map((o, i) => {
const label = `${o.refId}${o.count > 1 ? ' (' + o.count + ')' : ''}`; const label = `${o.refId}${o.count > 1 ? ' (' + o.count + ')' : ''}`;
const isSelected = selected.indexOf(o.refId) > -1; const isSelected = selected.indexOf(o.refId) > -1;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { cx } from 'emotion';
export interface CardProps {
logoUrl?: string;
title: string;
description?: string;
actions?: React.ReactNode;
onClick?: () => void;
ariaLabel?: string;
className?: string;
}
export const Card: React.FC<CardProps> = ({ logoUrl, title, description, actions, onClick, ariaLabel, className }) => {
const mainClassName = cx('add-data-source-item', className);
return (
<div className={mainClassName} onClick={onClick} aria-label={ariaLabel}>
{logoUrl && <img className="add-data-source-item-logo" src={logoUrl} />}
<div className="add-data-source-item-text-wrapper">
<span className="add-data-source-item-text">{title}</span>
{description && <span className="add-data-source-item-desc">{description}</span>}
</div>
{actions && <div className="add-data-source-item-actions">{actions}</div>}
</div>
);
};

View File

@@ -1,13 +1,25 @@
import React from 'react'; import React from 'react';
import { Container, CustomScrollbar, ValuePicker } from '@grafana/ui'; import {
Container,
CustomScrollbar,
InfoBox,
ValuePicker,
Button,
useTheme,
VerticalGroup,
stylesFactory,
} from '@grafana/ui';
import { import {
DataFrame, DataFrame,
DataTransformerConfig, DataTransformerConfig,
GrafanaTheme,
SelectableValue, SelectableValue,
standardTransformersRegistry, standardTransformersRegistry,
transformDataFrame, transformDataFrame,
} from '@grafana/data'; } from '@grafana/data';
import { TransformationOperationRow } from './TransformationOperationRow'; import { TransformationOperationRow } from './TransformationOperationRow';
import { Card, CardProps } from '../../../../core/components/Card/Card';
import { css } from 'emotion';
interface Props { interface Props {
onChange: (transformations: DataTransformerConfig[]) => void; onChange: (transformations: DataTransformerConfig[]) => void;
@@ -52,10 +64,10 @@ export class TransformationsEditor extends React.PureComponent<Props> {
return ( return (
<ValuePicker <ValuePicker
size="md"
variant="secondary" variant="secondary"
label="Add transformation" label="Add transformation"
options={availableTransformers} options={availableTransformers}
size="lg"
onChange={this.onTransformationAdd} onChange={this.onTransformationAdd}
isFullWidth={false} isFullWidth={false}
/> />
@@ -109,17 +121,54 @@ export class TransformationsEditor extends React.PureComponent<Props> {
}; };
render() { render() {
const hasTransformationsConfigured = this.props.transformations.length > 0;
return ( return (
<CustomScrollbar autoHeightMin="100%"> <CustomScrollbar autoHeightMin="100%">
<Container padding="md"> <Container padding="md">
<p className="muted"> {!hasTransformationsConfigured && (
Transformations allow you to combine, re-order, hide and rename specific parts the the data set before being <InfoBox>
visualized. <p>
</p> Transformations allow you to combine, re-order, hide and rename specific parts the the data set before
{this.renderTransformationEditors()} being visualized. Choose one of the transformations below to start with:
{this.renderTransformationSelector()} </p>
<VerticalGroup>
{standardTransformersRegistry.list().map(t => {
return (
<TransformationCard
title={t.name}
description={t.description}
actions={<Button>Select</Button>}
onClick={() => {
this.onTransformationAdd({ value: t.id });
}}
/>
);
})}
</VerticalGroup>
</InfoBox>
)}
{hasTransformationsConfigured && this.renderTransformationEditors()}
{hasTransformationsConfigured && this.renderTransformationSelector()}
</Container> </Container>
</CustomScrollbar> </CustomScrollbar>
); );
} }
} }
const TransformationCard: React.FC<CardProps> = props => {
const theme = useTheme();
const styles = getTransformationCardStyles(theme);
return <Card {...props} className={styles.card} />;
};
const getTransformationCardStyles = stylesFactory((theme: GrafanaTheme) => {
return {
card: css`
background: ${theme.colors.bg2};
width: 100%;
&:hover {
background: ${theme.colors.bg3};
}
`,
};
});

View File

@@ -1,5 +1,4 @@
import React, { FC, PureComponent } from 'react'; import React, { FC, PureComponent } from 'react';
import classNames from 'classnames';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { hot } from 'react-hot-loader'; import { hot } from 'react-hot-loader';
import { DataSourcePluginMeta, NavModel } from '@grafana/data'; import { DataSourcePluginMeta, NavModel } from '@grafana/data';
@@ -12,6 +11,7 @@ import { addDataSource, loadDataSourcePlugins } from './state/actions';
import { getDataSourcePlugins } from './state/selectors'; import { getDataSourcePlugins } from './state/selectors';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { setDataSourceTypeSearchQuery } from './state/reducers'; import { setDataSourceTypeSearchQuery } from './state/reducers';
import { Card } from 'app/core/components/Card/Card';
export interface Props { export interface Props {
navModel: NavModel; navModel: NavModel;
@@ -120,37 +120,33 @@ const DataSourceTypeCard: FC<DataSourceTypeCardProps> = props => {
// find first plugin info link // find first plugin info link
const learnMoreLink = plugin.info.links && plugin.info.links.length > 0 ? plugin.info.links[0] : null; const learnMoreLink = plugin.info.links && plugin.info.links.length > 0 ? plugin.info.links[0] : null;
const mainClassName = classNames('add-data-source-item', {
'add-data-source-item--phantom': isPhantom,
});
return ( return (
<div <Card
className={mainClassName} title={plugin.name}
description={plugin.info.description}
ariaLabel={e2e.pages.AddDataSource.selectors.dataSourcePlugins(plugin.name)}
logoUrl={plugin.info.logos.small}
actions={
<>
{learnMoreLink && (
<LinkButton
variant="secondary"
href={`${learnMoreLink.url}?utm_source=grafana_add_ds`}
target="_blank"
rel="noopener"
onClick={onLearnMoreClick}
icon="external-link-alt"
>
{learnMoreLink.name}
</LinkButton>
)}
{!isPhantom && <Button>Select</Button>}
</>
}
className={isPhantom && 'add-data-source-item--phantom'}
onClick={onClick} onClick={onClick}
aria-label={e2e.pages.AddDataSource.selectors.dataSourcePlugins(plugin.name)} />
>
<img className="add-data-source-item-logo" src={plugin.info.logos.small} />
<div className="add-data-source-item-text-wrapper">
<span className="add-data-source-item-text">{plugin.name}</span>
{plugin.info.description && <span className="add-data-source-item-desc">{plugin.info.description}</span>}
</div>
<div className="add-data-source-item-actions">
{learnMoreLink && (
<LinkButton
variant="secondary"
href={`${learnMoreLink.url}?utm_source=grafana_add_ds`}
target="_blank"
rel="noopener"
onClick={onLearnMoreClick}
icon="external-link-alt"
>
{learnMoreLink.name}
</LinkButton>
)}
{!isPhantom && <Button>Select</Button>}
</div>
</div>
); );
}; };