[Canvas] Added formatnumber/formatdate UIs to sidebar (#43059) (#43801)

* Added formatnumber and formatdate transform UIs

* Added rounddate transform

* Changed default custom format

* Changed to UTC date

* Fixed ts error

* Fixed help text

* Added type def for arguments

* Added types for tranforms

* Added snapshots

* Fixed prop
This commit is contained in:
Catherine Liu 2019-08-22 12:45:36 -07:00 committed by GitHub
parent cf6d86c295
commit 79cea1f906
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 621 additions and 53 deletions

View file

@ -8,7 +8,7 @@ import moment from 'moment';
import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/public';
import { getFunctionHelp } from '../../strings';
interface Arguments {
export interface Arguments {
format: string;
}

View file

@ -8,7 +8,7 @@ import numeral from '@elastic/numeral';
import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/public';
import { getFunctionHelp } from '../../strings';
interface Arguments {
export interface Arguments {
format: string;
}

View file

@ -8,7 +8,7 @@ import moment from 'moment';
import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/public';
import { getFunctionHelp } from '../../strings';
interface Arguments {
export interface Arguments {
format: string;
}

View file

@ -0,0 +1,212 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Storyshots arguments/DateFormat with custom format 1`] = `
Array [
<div
className="euiFormControlLayout euiFormControlLayout--compressed"
>
<div
className="euiFormControlLayout__childrenWrapper"
>
<select
className="euiSelect euiSelect--compressed"
id="DateFormatExample3"
onChange={[Function]}
onMouseUp={[Function]}
value="custom"
>
<option
value="l"
>
Shorthand
</option>
<option
value="x"
>
Unix
</option>
<option
value="LLL"
>
Longhand
</option>
<option
value="custom"
>
Custom
</option>
</select>
<div
className="euiFormControlLayoutIcons euiFormControlLayoutIcons--right"
>
<span
className="euiFormControlLayoutCustomIcon"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiIcon-isLoading euiFormControlLayoutCustomIcon__icon"
focusable="false"
height={16}
style={null}
viewBox="0 0 16 16"
width={16}
xmlns="http://www.w3.org/2000/svg"
/>
</span>
</div>
</div>
</div>,
<div
className="euiSpacer euiSpacer--s"
/>,
<div
className="euiFormControlLayout euiFormControlLayout--compressed"
>
<div
className="euiFormControlLayout__childrenWrapper"
>
<input
className="euiFieldText euiFieldText--compressed"
onChange={[Function]}
placeholder="M/D/YY h:ma"
type="text"
value="MM/DD/YYYY"
/>
</div>
</div>,
]
`;
exports[`Storyshots arguments/DateFormat with no format 1`] = `
Array [
<div
className="euiFormControlLayout euiFormControlLayout--compressed"
>
<div
className="euiFormControlLayout__childrenWrapper"
>
<select
className="euiSelect euiSelect--compressed"
id="DateFormatExample1"
onChange={[Function]}
onMouseUp={[Function]}
value="custom"
>
<option
value="l"
>
Shorthand
</option>
<option
value="x"
>
Unix
</option>
<option
value="LLL"
>
Longhand
</option>
<option
value="custom"
>
Custom
</option>
</select>
<div
className="euiFormControlLayoutIcons euiFormControlLayoutIcons--right"
>
<span
className="euiFormControlLayoutCustomIcon"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiIcon-isLoading euiFormControlLayoutCustomIcon__icon"
focusable="false"
height={16}
style={null}
viewBox="0 0 16 16"
width={16}
xmlns="http://www.w3.org/2000/svg"
/>
</span>
</div>
</div>
</div>,
<div
className="euiSpacer euiSpacer--s"
/>,
<div
className="euiFormControlLayout euiFormControlLayout--compressed"
>
<div
className="euiFormControlLayout__childrenWrapper"
>
<input
className="euiFieldText euiFieldText--compressed"
onChange={[Function]}
placeholder="M/D/YY h:ma"
type="text"
value=""
/>
</div>
</div>,
]
`;
exports[`Storyshots arguments/DateFormat with preset format 1`] = `
<div
className="euiFormControlLayout euiFormControlLayout--compressed"
>
<div
className="euiFormControlLayout__childrenWrapper"
>
<select
className="euiSelect euiSelect--compressed"
id="DateFormatExample2"
onChange={[Function]}
onMouseUp={[Function]}
value="LLL"
>
<option
value="l"
>
Shorthand
</option>
<option
value="x"
>
Unix
</option>
<option
value="LLL"
>
Longhand
</option>
<option
value="custom"
>
Custom
</option>
</select>
<div
className="euiFormControlLayoutIcons euiFormControlLayoutIcons--right"
>
<span
className="euiFormControlLayoutCustomIcon"
>
<svg
aria-hidden="true"
className="euiIcon euiIcon--medium euiIcon-isLoading euiFormControlLayoutCustomIcon__icon"
focusable="false"
height={16}
style={null}
viewBox="0 0 16 16"
width={16}
xmlns="http://www.w3.org/2000/svg"
/>
</span>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { storiesOf } from '@storybook/react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import { DateFormatArgInput } from '../date_format';
const dateFormats = [
{ value: 'l', text: 'Shorthand' },
{ value: 'x', text: 'Unix' },
{ value: 'LLL', text: 'Longhand' },
];
storiesOf('arguments/DateFormat', module)
.add('with no format', () => (
<DateFormatArgInput
dateFormats={dateFormats}
onValueChange={action('onValueChange')}
argValue=""
argId="DateFormatExample1"
renderError={action('renderError')}
/>
))
.add('with preset format', () => (
<DateFormatArgInput
dateFormats={dateFormats}
onValueChange={action('onValueChange')}
argValue="LLL"
argId="DateFormatExample2"
renderError={action('renderError')}
/>
))
.add('with custom format', () => (
<DateFormatArgInput
dateFormats={dateFormats}
onValueChange={action('onValueChange')}
argValue="MM/DD/YYYY"
argId="DateFormatExample3"
renderError={action('renderError')}
/>
));

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FunctionComponent } from 'react';
import PropTypes from 'prop-types';
import { FormatSelect } from '../../../../public/components/format_select/format_select';
import { ArgumentProps } from '../../../../types/arguments';
interface DateFormatOption {
/** A MomentJS format string */
value: string;
/** The name to display for the format */
text: string;
}
export interface Props extends ArgumentProps {
/** An array of number formats options */
dateFormats: DateFormatOption[];
/** The handler to invoke when value changes */
onValueChange: (value: string) => void;
/** The value of the argument */
argValue: string;
/** The ID for the argument */
argId: string;
}
export const DateFormatArgInput: FunctionComponent<Props> = ({
dateFormats,
onValueChange,
argValue,
argId,
}) => (
<FormatSelect
argId={argId}
argValue={argValue}
formatOptions={dateFormats}
onValueChange={onValueChange}
defaultCustomFormat="M/D/YY h:ma"
/>
);
DateFormatArgInput.propTypes = {
dateFormats: PropTypes.arrayOf(
PropTypes.shape({ value: PropTypes.string, text: PropTypes.string })
).isRequired,
onValueChange: PropTypes.func.isRequired,
argValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]).isRequired,
argId: PropTypes.string.isRequired,
};

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { compose, withProps } from 'recompose';
import moment from 'moment';
import { DateFormatArgInput as Component, Props as ComponentProps } from './date_format';
import { AdvancedSettings } from '../../../../public/lib/kibana_advanced_settings';
// @ts-ignore untyped local lib
import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component';
import { ArgumentFactory } from '../../../../types/arguments';
const formatMap = {
DEFAULT: AdvancedSettings.get('dateFormat'),
NANOS: AdvancedSettings.get('dateNanosFormat'),
ISO8601: '',
LOCAL_LONG: 'LLLL',
LOCAL_SHORT: 'LLL',
LOCAL_DATE: 'l',
LOCAL_TIME_WITH_SECONDS: 'LTS',
};
const now = moment();
const dateFormats = Object.values(formatMap).map(format => ({
value: format,
text: moment.utc(now).format(format),
}));
export const DateFormatArgInput = compose<ComponentProps, null>(withProps({ dateFormats }))(
Component
);
export const dateFormat: ArgumentFactory<ComponentProps> = () => ({
name: 'dateFormat',
displayName: 'Date Format',
help: 'Select or enter a MomentJS format',
simpleTemplate: templateFromReactComponent(DateFormatArgInput),
});

View file

@ -6,6 +6,7 @@
import { axisConfig } from './axis_config';
import { datacolumn } from './datacolumn';
import { dateFormat } from './date_format';
import { filterGroup } from './filter_group';
import { imageUpload } from './image_upload';
import { number } from './number';
@ -22,6 +23,7 @@ import { toggle } from './toggle';
export const args = [
axisConfig,
datacolumn,
dateFormat,
filterGroup,
imageUpload,
number,

View file

@ -13,7 +13,7 @@ Array [
id="NumberFormatExample3"
onChange={[Function]}
onMouseUp={[Function]}
value=""
value="custom"
>
<option
value="0.0[000]"
@ -41,7 +41,7 @@ Array [
Bytes
</option>
<option
value=""
value="custom"
>
Custom
</option>
@ -100,7 +100,7 @@ Array [
id="NumberFormatExample1"
onChange={[Function]}
onMouseUp={[Function]}
value=""
value="custom"
>
<option
value="0.0[000]"
@ -128,7 +128,7 @@ Array [
Bytes
</option>
<option
value=""
value="custom"
>
Custom
</option>
@ -214,7 +214,7 @@ exports[`Storyshots arguments/NumberFormat with preset format 1`] = `
Bytes
</option>
<option
value=""
value="custom"
>
Custom
</option>

View file

@ -23,6 +23,7 @@ storiesOf('arguments/NumberFormat', module)
onValueChange={action('onValueChange')}
argValue=""
argId="NumberFormatExample1"
renderError={action('renderError')}
/>
))
.add('with preset format', () => (
@ -31,6 +32,7 @@ storiesOf('arguments/NumberFormat', module)
onValueChange={action('onValueChange')}
argValue="$0.00"
argId="NumberFormatExample2"
renderError={action('renderError')}
/>
))
.add('with custom format', () => (
@ -39,5 +41,6 @@ storiesOf('arguments/NumberFormat', module)
onValueChange={action('onValueChange')}
argValue="0.0[000]a"
argId="NumberFormatExample3"
renderError={action('renderError')}
/>
));

View file

@ -9,6 +9,7 @@ import { NumberFormatArgInput as Component, Props as ComponentProps } from './nu
import { AdvancedSettings } from '../../../../public/lib/kibana_advanced_settings';
// @ts-ignore untyped local lib
import { templateFromReactComponent } from '../../../../public/lib/template_from_react_component';
import { ArgumentFactory } from '../../../../types/arguments';
const formatMap = {
NUMBER: AdvancedSettings.get('format:number:defaultPattern'),
@ -30,7 +31,7 @@ export const NumberFormatArgInput = compose<ComponentProps, null>(withProps({ nu
Component
);
export const numberFormat = () => ({
export const numberFormat: ArgumentFactory<ComponentProps> = () => ({
name: 'numberFormat',
displayName: 'Number Format',
help: 'Select or enter a valid NumeralJS format',

View file

@ -4,9 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, ChangeEvent, FunctionComponent } from 'react';
import React, { FunctionComponent } from 'react';
import PropTypes from 'prop-types';
import { EuiSelect, EuiFieldText, EuiSpacer } from '@elastic/eui';
import { FormatSelect } from '../../../../public/components/format_select/format_select';
import { ArgumentProps } from '../../../../types/arguments';
interface NumberFormatOption {
/** A NumeralJS format string */
@ -15,7 +16,7 @@ interface NumberFormatOption {
text: string;
}
export interface Props {
export interface Props extends ArgumentProps {
/** An array of number formats options */
numberFormats: NumberFormatOption[];
/** The handler to invoke when value changes */
@ -31,42 +32,20 @@ export const NumberFormatArgInput: FunctionComponent<Props> = ({
onValueChange,
argValue,
argId,
}) => {
const formatOptions = numberFormats.concat({ value: '', text: 'Custom' });
const handleTextChange = (ev: ChangeEvent<HTMLInputElement>) => onValueChange(ev.target.value);
const handleSelectChange = (ev: ChangeEvent<HTMLSelectElement>) => {
const { value } = formatOptions[ev.target.selectedIndex];
return onValueChange(value || '0.0a');
};
// checks if the argValue is one of the preset formats
const isCustomFormat = !argValue || !formatOptions.map(({ value }) => value).includes(argValue);
return (
<Fragment>
<EuiSelect
compressed
id={argId}
value={isCustomFormat ? '' : argValue}
options={formatOptions}
onChange={handleSelectChange}
/>
{isCustomFormat && (
<Fragment>
<EuiSpacer size="s" />
<EuiFieldText
placeholder="0.0a"
value={argValue}
compressed
onChange={handleTextChange}
/>
</Fragment>
)}
</Fragment>
);
};
}) => (
<FormatSelect
argId={argId}
argValue={argValue}
formatOptions={numberFormats}
onValueChange={onValueChange}
defaultCustomFormat="0.0a"
/>
);
NumberFormatArgInput.propTypes = {
numberFormats: PropTypes.arrayOf(
PropTypes.shape({ value: PropTypes.string, text: PropTypes.string })
).isRequired,
onValueChange: PropTypes.func.isRequired,
argValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]).isRequired,
argId: PropTypes.string.isRequired,

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { TransformFactory } from '../../../types/transforms';
import { Arguments } from '../../functions/common/formatdate';
export const formatdate: TransformFactory<Arguments> = () => ({
name: 'formatdate',
displayName: 'Date format',
args: [
{
name: 'format',
displayName: 'Format',
argType: 'dateformat',
},
],
});

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { TransformFactory } from '../../../types/transforms';
import { Arguments } from '../../functions/common/formatnumber';
export const formatnumber: TransformFactory<Arguments> = () => ({
name: 'formatnumber',
displayName: 'Number format',
args: [
{
name: 'format',
displayName: 'Format',
argType: 'numberformat',
},
],
});

View file

@ -4,6 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { formatdate } from './formatdate';
import { formatnumber } from './formatnumber';
import { rounddate } from './rounddate';
import { sort } from './sort';
export const transformSpecs = [sort];
export const transformSpecs = [formatdate, formatnumber, rounddate, sort];

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { TransformFactory } from '../../../types/transforms';
import { Arguments } from '../../functions/common/rounddate';
export const rounddate: TransformFactory<Arguments> = () => ({
name: 'rounddate',
displayName: 'Round date',
args: [
{
name: 'format',
displayName: 'Format',
argType: 'dateformat',
help: 'Select or enter a MomentJS format to round the date',
},
],
});

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, PureComponent, ChangeEvent } from 'react';
import PropTypes from 'prop-types';
import { EuiSelect, EuiSpacer, EuiFieldText } from '@elastic/eui';
interface Props {
/** The ID of the argument form */
argId: string;
/** The current format string value */
argValue: string;
/** The preset format options to populate the select control */
formatOptions: Array<{ value: string; text: string }>;
/** The default custom format to initially populate the text field with */
defaultCustomFormat: string;
/** The handler to commit the new value */
onValueChange: (value: string) => void;
}
export class FormatSelect extends PureComponent<Props> {
static propTypes = {
argId: PropTypes.string,
argValue: PropTypes.string,
formatOptions: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string,
text: PropTypes.string,
})
).isRequired,
onValueChange: PropTypes.func,
};
state = {
isCustomFormat: !this.props.formatOptions
.map(({ value }) => value)
.includes(this.props.argValue),
};
_options = this.props.formatOptions.concat({ value: 'custom', text: 'Custom' });
_handleTextChange = (ev: ChangeEvent<HTMLInputElement>) =>
this.props.onValueChange(ev.target.value);
_handleSelectChange = (ev: ChangeEvent<HTMLSelectElement>) => {
const { onValueChange, defaultCustomFormat } = this.props;
const { value } = this._options[ev.target.selectedIndex];
if (value === 'custom') {
this.setState({ isCustomFormat: true });
return onValueChange(defaultCustomFormat);
}
if (this.state.isCustomFormat) {
this.setState({ isCustomFormat: false });
}
return onValueChange(value);
};
render() {
const { argId, argValue, defaultCustomFormat } = this.props;
const { isCustomFormat } = this.state;
return (
<Fragment>
<EuiSelect
compressed
id={argId}
value={isCustomFormat ? 'custom' : argValue}
options={this._options}
onChange={this._handleSelectChange}
/>
{isCustomFormat && (
<Fragment>
<EuiSpacer size="s" />
<EuiFieldText
placeholder={defaultCustomFormat}
value={argValue}
compressed
onChange={this._handleTextChange}
/>
</Fragment>
)}
</Fragment>
);
}
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { FormatSelect } from './format_select';

View file

@ -8,16 +8,12 @@ import React, { ComponentType, FunctionComponent } from 'react';
import { unmountComponentAtNode, render } from 'react-dom';
import PropTypes from 'prop-types';
import { ErrorBoundary } from '../components/enhance/error_boundary';
import { ArgumentHandlers } from '../../types/arguments';
interface Props {
renderError: Function;
}
interface Handlers {
done: () => void;
onDestroy: (fn: () => void) => void;
}
export const templateFromReactComponent = (Component: ComponentType<any>) => {
const WrappedComponent: FunctionComponent<Props> = props => (
<ErrorBoundary>
@ -36,7 +32,7 @@ export const templateFromReactComponent = (Component: ComponentType<any>) => {
renderError: PropTypes.func,
};
return (domNode: Element, config: Props, handlers: Handlers) => {
return (domNode: HTMLElement, config: Props, handlers: ArgumentHandlers) => {
try {
const el = React.createElement(WrappedComponent, config);
render(el, domNode, () => {

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
type GenericCallback = (callback: () => void) => void;
export interface ArgumentProps {
/** Handler to invoke when error has thrown in the argument form */
renderError: () => void;
}
export interface ArgumentHandlers {
/** Handler to invoke when an argument form has finished rendering */
done: () => void;
/** Handler to invoke when an argument form is removed */
onDestroy: GenericCallback;
}
export interface ArgumentSpec<ArgumentConfig = {}> {
/** The argument type */
name: string;
/** The name to display */
displayName: string;
/** A description of what is rendered */
help: string;
/**
* A function that renders a compact, non-collapsible argument form
* If template is also provided, then this form goes in the accordion header
* */
simpleTemplate?: (
domNode: HTMLElement,
config: ArgumentConfig,
handlers: ArgumentHandlers
) => void;
/**
* A function that renders a complex/large argument
* This is nested in an accordian so it can be expanded/collapsed
*/
template?: (domNode: HTMLElement, config: ArgumentConfig, handlers: ArgumentHandlers) => void;
}
export type ArgumentFactory<ArgumentConfig = {}> = () => ArgumentSpec<ArgumentConfig>;
// Settings for the argument to display in the sidebar
export interface ArgumentConfig<Arguments = {}> {
/** The name of the function argument configured by this argument form */
name: keyof Arguments;
/** The name to display */
displayName: string;
/** The argument type */
argType: string;
/** A description of the argument */
help?: string;
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ArgumentConfig } from './arguments';
export interface TransformSpec<Arguments = {}> {
/** The name of the function to create a transform section in the sidebar for */
name: string;
/** The name to display */
displayName: string;
/** A description of what is rendered */
help?: string;
/** A list of arguments to display in the */
args: Array<ArgumentConfig<Arguments>>;
}
export type TransformFactory<Arguments = {}> = () => TransformSpec<Arguments>;