diff --git a/CHANGELOG.md b/CHANGELOG.md index fa16cd4df3cf..c8713fccf280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,19 @@ ## [`master`](https://github.com/elastic/eui/tree/master) +- Added `onRefresh` option for `EuiSuperDatePicker` ([#1577](https://github.com/elastic/eui/pull/1577)) - Converted `EuiToggle` to TypeScript ([#1570](https://github.com/elastic/eui/pull/1570)) - Added type definitions for `EuiButtonGroup`,`EuiButtonToggle`, `EuiFilterButton`, `EuiFilterGroup`, and `EuiFilterSelectItem` ([#1570](https://github.com/elastic/eui/pull/1570)) +- Added `displayOnly` prop to EuiFormRow ([#1582](https://github.com/elastic/eui/pull/1582)) +- Added an index.d.ts file for the date picker components, including `EuiDatePicker`, `EuiDatePickerRange`, and `EuiSuperDatePicker` ([#1574](https://github.com/elastic/eui/pull/1574)) - Added `displayOnly` prop to `EuiFormRow` ([#1582](https://github.com/elastic/eui/pull/1582)) - Added `numActiveFilters` prop to `EuiFilterButton` ([#1589](https://github.com/elastic/eui/pull/1589)) - Updated style of `EuiFilterButton` to match `EuiFacetButton` ([#1589](https://github.com/elastic/eui/pull/1589)) - Added `size` and `color` props to `EuiNotificationBadge` ([#1589](https://github.com/elastic/eui/pull/1589)) +**Bug fixes** + +- Fixed several bugs with `EuiRange` and `EuiDualRange` including sizing of inputs, tick placement, and the handling of invalid values ([#1580](https://github.com/elastic/eui/pull/1580)) + ## [`7.2.0`](https://github.com/elastic/eui/tree/v7.2.0) - Added `text` as a color option for `EuiLink` ([#1571](https://github.com/elastic/eui/pull/1571)) diff --git a/package.json b/package.json index ffda7c859ccb..5540a1281b7c 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@types/enzyme": "^3.1.13", "@types/jest": "^23.3.9", "@types/react": "^16.3.0", + "@types/react-datepicker": "1.8.0", "@types/react-is": "~16.3.0", "@types/react-virtualized": "^9.18.6", "@types/uuid": "^3.4.4", diff --git a/src-docs/src/views/date_picker/custom_input.js b/src-docs/src/views/date_picker/custom_input.js index 702fa600f5ad..deab042bee2f 100644 --- a/src-docs/src/views/date_picker/custom_input.js +++ b/src-docs/src/views/date_picker/custom_input.js @@ -11,7 +11,7 @@ import { EuiButton, } from '../../../../src/components'; -// Should be a component because the datepicker does some ref stuff behind the scenes +// Should be a component because the date picker does some ref stuff behind the scenes // eslint-disable-next-line react/prefer-stateless-function class ExampleCustomInput extends React.Component { diff --git a/src-docs/src/views/date_picker/date_picker_example.js b/src-docs/src/views/date_picker/date_picker_example.js index 1f2c69f95c20..6a977a3969bc 100644 --- a/src-docs/src/views/date_picker/date_picker_example.js +++ b/src-docs/src/views/date_picker/date_picker_example.js @@ -64,7 +64,7 @@ const superDatePickerSource = require('!!raw-loader!./super_date_picker'); const superDatePickerHtml = renderToHtml(SuperDatePicker); export const DatePickerExample = { - title: 'DatePicker', + title: 'Date Picker', sections: [{ source: [{ type: GuideSectionTypes.JS, @@ -85,7 +85,7 @@ export const DatePickerExample = { demo: , props: { EuiDatePicker }, }, { - title: 'Datepicker states', + title: 'Date picker states', source: [{ type: GuideSectionTypes.JS, code: statesSource, @@ -140,7 +140,7 @@ export const DatePickerExample = { ), demo: , }, { - title: 'Datepicker range', + title: 'Date picker range', source: [{ type: GuideSectionTypes.JS, code: rangeSource, @@ -227,7 +227,7 @@ export const DatePickerExample = { ), demo: , }, { - title: 'Datepicker inline', + title: 'Date picker inline', source: [{ type: GuideSectionTypes.JS, code: inlineSource, diff --git a/src-docs/src/views/date_picker/super_date_picker.js b/src-docs/src/views/date_picker/super_date_picker.js index 3cbf018c4df3..791c88b2ce49 100644 --- a/src-docs/src/views/date_picker/super_date_picker.js +++ b/src-docs/src/views/date_picker/super_date_picker.js @@ -51,6 +51,14 @@ export default class extends Component { }, this.startLoading); } + onRefresh = ({ start, end, refreshInterval }) => { + return new Promise((resolve) => { + setTimeout(resolve, 100); + }).then(() => { + console.log(start, end, refreshInterval); + }); + } + onStartInputChange = e => { this.setState({ start: e.target.value, @@ -168,6 +176,7 @@ export default class extends Component { start={this.state.start} end={this.state.end} onTimeChange={this.onTimeChange} + onRefresh={this.onRefresh} isPaused={this.state.isPaused} refreshInterval={this.state.refreshInterval} onRefreshChange={this.onRefreshChange} diff --git a/src-docs/src/views/range/dual_range.js b/src-docs/src/views/range/dual_range.js index 0d7663fef5dd..105673fb9f82 100644 --- a/src-docs/src/views/range/dual_range.js +++ b/src-docs/src/views/range/dual_range.js @@ -1,12 +1,9 @@ import React, { Component, - Fragment, } from 'react'; import { EuiDualRange, - EuiSpacer, - EuiFormHelpText, } from '../../../../src/components'; import makeId from '../../../../src/components/form/form_row/make_id'; @@ -15,21 +12,8 @@ export default class extends Component { constructor(props) { super(props); - this.levels = [ - { - min: 0, - max: 600, - color: 'danger' - }, - { - min: 600, - max: 2000, - color: 'success' - } - ]; - this.state = { - value: [120, 480] + value: ['', ''], }; } @@ -41,96 +25,15 @@ export default class extends Component { render() { return ( - - - - - - - - - - - - - - - - Recommended levels are 600 and above. - - - - - - - - - + ); } } diff --git a/src-docs/src/views/range/input.js b/src-docs/src/views/range/input.js new file mode 100644 index 000000000000..d6cfbec4aaf8 --- /dev/null +++ b/src-docs/src/views/range/input.js @@ -0,0 +1,57 @@ +import React, { + Component, + Fragment, +} from 'react'; + +import { + EuiRange, + EuiSpacer, + EuiDualRange, +} from '../../../../src/components'; + +import makeId from '../../../../src/components/form/form_row/make_id'; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + value: '20', + dualValue: [20, 100], + }; + } + + onChange = e => { + this.setState({ + value: e.target.value, + }); + }; + + onDualChange = (value) => { + this.setState({ + dualValue: value + }); + }; + + render() { + return ( + + + + + + + + ); + } +} diff --git a/src-docs/src/views/range/levels.js b/src-docs/src/views/range/levels.js new file mode 100644 index 000000000000..ce2a4c874faf --- /dev/null +++ b/src-docs/src/views/range/levels.js @@ -0,0 +1,80 @@ +import React, { + Component, + Fragment, +} from 'react'; + +import { + EuiRange, + EuiSpacer, + EuiFormHelpText, + EuiDualRange, +} from '../../../../src/components'; + +import makeId from '../../../../src/components/form/form_row/make_id'; + +export default class extends Component { + constructor(props) { + super(props); + + this.levels = [ + { + min: 0, + max: 20, + color: 'danger' + }, + { + min: 20, + max: 100, + color: 'success' + } + ]; + + this.state = { + value: '20', + dualValue: [20, 100], + }; + } + + onChange = e => { + this.setState({ + value: e.target.value, + }); + }; + + onDualChange = (value) => { + this.setState({ + dualValue: value + }); + }; + + render() { + return ( + + + Recommended levels are {this.levels[1].min} and above. + + + + + Recommended size is {this.levels[1].min}kb and above. + + ); + } +} diff --git a/src-docs/src/views/range/range.js b/src-docs/src/views/range/range.js index 8c1dc43695d2..3762d8cc4cd9 100644 --- a/src-docs/src/views/range/range.js +++ b/src-docs/src/views/range/range.js @@ -6,7 +6,6 @@ import React, { import { EuiRange, EuiSpacer, - EuiFormHelpText, } from '../../../../src/components'; import makeId from '../../../../src/components/form/form_row/make_id'; @@ -15,19 +14,6 @@ export default class extends Component { constructor(props) { super(props); - this.levels = [ - { - min: 0, - max: 600, - color: 'danger' - }, - { - min: 600, - max: 2000, - color: 'success' - } - ]; - this.state = { value: '120' }; @@ -48,10 +34,7 @@ export default class extends Component { max={200} value={this.state.value} onChange={this.onChange} - aria-label="Use aria labels when no actual label is in use" showLabels - showValue - name="firstRange" /> @@ -62,9 +45,8 @@ export default class extends Component { max={200} value={this.state.value} onChange={this.onChange} - disabled - aria-label="Use aria labels when no actual label is in use" showLabels + showValue /> @@ -75,61 +57,10 @@ export default class extends Component { max={200} value={this.state.value} onChange={this.onChange} - aria-label="Use aria labels when no actual label is in use" showLabels - showInput - showRange - /> - - - - - Recommended levels are 600 and above. - - - - - - - - ); diff --git a/src-docs/src/views/range/range_example.js b/src-docs/src/views/range/range_example.js index 10ba13cb139e..27ef79e70023 100644 --- a/src-docs/src/views/range/range_example.js +++ b/src-docs/src/views/range/range_example.js @@ -10,9 +10,24 @@ import { EuiCallOut, EuiDualRange, EuiRange, - EuiSpacer + EuiSpacer, + EuiCode, } from '../../../../src/components'; +import { + EuiRangeLevels, + LEVEL_COLORS, +} from '../../../../src/components/form/range/range_levels'; + +import { + EuiRangeTicks, +} from '../../../../src/components/form/range/range_ticks'; + + +import { + EuiRangeInput, +} from '../../../../src/components/form/range/range_input'; + import DualRangeExample from './dual_range'; const dualRangeSource = require('!!raw-loader!./dual_range'); const dualRangeHtml = renderToHtml(DualRangeExample); @@ -21,28 +36,69 @@ import RangeExample from './range'; const rangeSource = require('!!raw-loader!./range'); const rangeHtml = renderToHtml(RangeExample); +import InputExample from './input'; +const inputSource = require('!!raw-loader!./input'); +const inputHtml = renderToHtml(InputExample); + +import TicksExample from './ticks'; +const ticksSource = require('!!raw-loader!./ticks'); +const ticksHtml = renderToHtml(TicksExample); + +import LevelsExample from './levels'; +const levelsSource = require('!!raw-loader!./levels'); +const levelsHtml = renderToHtml(LevelsExample); + +import StatesExample from './states'; +const statesSource = require('!!raw-loader!./states'); +const statesHtml = renderToHtml(StatesExample); + export const RangeControlExample = { - title: 'Range', + title: 'Range sliders', intro: (

Range sliders should only be used when the precise value is not considered important. If - the precise value does matter, add the showInput prop or use - a EuiFieldNumber instead. -

-

- While currently considered optional, the showLabels property should - be added to explicitly state the range to the user. + the precise value does matter, add the showInput prop or use + a EuiFieldNumber instead.

- +
), sections: [ { - title: 'Range', + title: 'Single range', + text: ( + +

Required

+
    +
  • min, max: + Sets the range values. +
  • +
  • step: + Technically not required because the default is 1. +
  • +
  • value, onChange
  • +
+

Optional

+
    +
  • showLabels: + While currently considered optional, the property should + be added to explicitly state the range to the user. +
  • +
  • showValue: + Displays a tooltip style indicator of the selected value. You can + add valuePrepend and/or valueAppend to + bookend the value with custom content. +
  • +
  • showRange: + Displays a thickened line from the minimum value to the selected value. +
  • +
+
+ ), source: [{ type: GuideSectionTypes.JS, code: rangeSource, @@ -54,28 +110,61 @@ export const RangeControlExample = { EuiRange, }, demo: , + snippet: ` + +// Show tooltip + + +// Show thickened range and prepend a string to the tooltip +`, }, { - title: 'DualRange', + title: 'Dual range', text: ( +

+ The EuiDualRange accepts almost all the same props as the regular EuiRange, with + the exception of showRange which is on by default, and showValue since + tooltips don't fit properly when there are two. +

- Two-value input[type=range] elements are not part of the HTML5 specification. - Because of this support gap, EuiDualRange cannot expose a native value property - for native form to consumption. + Two-value input[type=range] elements are not part of the HTML5 specification. + Because of this support gap, EuiDualRange cannot expose a native value property + for native form to consumption. - The React onChange prop is the recommended method + The React onChange prop is the recommended method for retrieving the upper and lower values.

- EuiDualRange does use native inputs to help validate step values - and range limits. These may be used as form values when showInput is in use. - The alternative is to store values in input[type=hidden]. + EuiDualRange does use native inputs to help validate step values + and range limits. These may be used as form values when showInput is in use. + The alternative is to store values in input[type=hidden].

-
), source: [{ @@ -89,6 +178,171 @@ export const RangeControlExample = { EuiDualRange, }, demo: , - } + snippet: ``, + }, + { + title: 'Inputs', + text: ( + +

+ The showInput prop, will append or bookend the range slider with number type inputs. + This is important for allowing precise values to be entered by the user. +

+

+ Passing empty strings as the value to the ranges, will allow the inputs to be blank, though + the range handles will show at the min (or max and min) positions. +

+
+ ), + source: [{ + type: GuideSectionTypes.JS, + code: inputSource, + }, { + type: GuideSectionTypes.HTML, + code: inputHtml, + }], + demo: , + props: { EuiRangeInput }, + snippet: ` + +`, + }, + { + title: 'Tick marks', + text: ( + +

+ To show clickable tick marks and labels at a given interval, add the prop showTicks. + By default, tick mark interval is bound to the step prop, however, you can set a custom + interval without changing the actual steps allowed by passing a number to the tickInterval prop. +

+

+ To pass completely custom tick marks, you can pass an array of objects that + require a value and label. The value must be included in the range of values + (min-max), though the label may be anythin you choose. +

+ +

Spacing can get quite cramped with lots of ticks so we max out the number to 20.

+
+
+ ), + source: [{ + type: GuideSectionTypes.JS, + code: ticksSource, + }, { + type: GuideSectionTypes.HTML, + code: ticksHtml, + }], + demo: , + props: { EuiRangeTicks }, + snippet: ` + + + +`, + }, + { + title: 'Levels', + text: ( + +

+ To create colored indicators for certain intervals, pass an array of objects that + include a min, max and color. + Color options are {JSON.stringify(LEVEL_COLORS, null, 2)}. +

+

+ Be sure to then add an aria-describedby and match it to the + id of a EuiFormHelpText. +

+
+ ), + source: [{ + type: GuideSectionTypes.JS, + code: levelsSource, + }, { + type: GuideSectionTypes.HTML, + code: levelsHtml, + }], + demo: , + props: { EuiRangeLevels }, + snippet: ` + +`, + }, + { + title: 'Kitchen sink', + text: ( + +

+ Other alterations you can add to the range are compressed, fullWidth, + and disabled. +

+
+ ), + source: [{ + type: GuideSectionTypes.JS, + code: statesSource, + }, { + type: GuideSectionTypes.HTML, + code: statesHtml, + }], + demo: , + snippet: ` {}} + compressed + fullWidth + disabled + showTicks + showInput + showLabels + showValue + showRange + tickInterval={} + levels={[]} + aria-describedBy={replaceWithID} +/> + + {}} + compressed + fullWidth + disabled + showLabels + showInput + showTicks + ticks={[]} + levels={[]} + aria-describedBy={replaceWithID} +/>`, + }, ] }; diff --git a/src-docs/src/views/range/states.js b/src-docs/src/views/range/states.js new file mode 100644 index 000000000000..f693b5bd85f2 --- /dev/null +++ b/src-docs/src/views/range/states.js @@ -0,0 +1,91 @@ +import React, { + Component, + Fragment, +} from 'react'; + +import { + EuiRange, + EuiSpacer, + EuiFormHelpText, + EuiDualRange, +} from '../../../../src/components'; + +import makeId from '../../../../src/components/form/form_row/make_id'; + +export default class extends Component { + constructor(props) { + super(props); + + this.levels = [ + { + min: 0, + max: 20, + color: 'danger' + }, + { + min: 20, + max: 100, + color: 'success' + } + ]; + + this.state = { + value: '20', + dualValue: [20, 100], + }; + } + + onChange = e => { + this.setState({ + value: e.target.value, + }); + }; + + onDualChange = (value) => { + this.setState({ + dualValue: value + }); + }; + + render() { + return ( + + + Recommended levels are {this.levels[1].min} and above. + + + + + Recommended size is {this.levels[1].min}kb and above. + + ); + } +} diff --git a/src-docs/src/views/range/ticks.js b/src-docs/src/views/range/ticks.js new file mode 100644 index 000000000000..4d42524081d8 --- /dev/null +++ b/src-docs/src/views/range/ticks.js @@ -0,0 +1,81 @@ +import React, { + Component, + Fragment, +} from 'react'; + +import { + EuiRange, + EuiSpacer, + EuiTitle, + EuiDualRange, +} from '../../../../src/components'; + +import makeId from '../../../../src/components/form/form_row/make_id'; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + value: '20', + dualValue: [20, 100], + }; + } + + onChange = e => { + this.setState({ + value: e.target.value, + }); + }; + + onDualChange = (value) => { + this.setState({ + dualValue: value + }); + }; + + render() { + return ( + + + + + +

Custom tick interval

+ + + + + + + +

Custom ticks object

+ + + + +
+ ); + } +} diff --git a/src/components/date_picker/index.d.ts b/src/components/date_picker/index.d.ts new file mode 100644 index 000000000000..f8032489cd7c --- /dev/null +++ b/src/components/date_picker/index.d.ts @@ -0,0 +1,76 @@ +import React from 'react'; +import { CommonProps } from '../common'; +import { IconType } from '../icon'; +import ReactDatePicker, { ReactDatePickerProps } from 'react-datepicker'; +import { Moment } from 'moment'; + +declare module '@elastic/eui' { + interface OnTimeChangeProps { + start: string; + end: string; + isInvalid: boolean; + isQuickSelection: boolean; + } + + interface OnRefreshChangeProps { + isPaused: boolean; + refreshInterval: number; + } + + interface EuiExtendedDatePickerProps extends ReactDatePickerProps { + fullWidth?: boolean; + isInvalid?: boolean; + isLoading?: boolean; + injectTimes?: Moment[]; // added here because the type is missing in @types/react-datepicker@1.8.0 + inputRef?: React.Ref; + placeholder?: string; + shadow?: boolean; + showIcon?: boolean; + } + + export type EuiDatePickerProps = CommonProps & EuiExtendedDatePickerProps; + export const EuiDatePicker: React.SFC; + + export type EuiDatePickerRangeProps = CommonProps & { + startDateControl: React.ReactElement; + endDateControl: React.ReactElement; + iconType?: boolean | IconType; + fullWidth?: boolean; + isCustom?: boolean; + }; + + export const EuiDatePickerRange: React.SFC; + + export interface EuiSuperDatePickerCommonRange { + start: string; + end: string; + label: string; + } + + export interface EuiSuperDatePickerRecentRange { + start: string; + end: string; + } + + export interface EuiSuperDatePickerQuickSelectPanel { + title: string; + content: React.ReactNode; + } + + export type EuiSuperDatePickerProps = CommonProps & { + start?: string; + end?: string; + isPaused?: boolean; + refreshInterval?: number; + onTimeChange: (props: OnTimeChangeProps) => void; + onRefreshChange?: (props: OnRefreshChangeProps) => void; + commonlyUsedRanges?: EuiSuperDatePickerCommonRange[]; + dateFormat?: string; + recentlyUsedRanges?: EuiSuperDatePickerRecentRange[]; + showUpdateButton?: boolean; + isAutoRefreshOnly?: boolean; + customQuickSelectPanels?: EuiSuperDatePickerQuickSelectPanel[]; + }; + + export const EuiSuperDatePicker: React.SFC; +} diff --git a/src/components/date_picker/super_date_picker/__snapshots__/super_date_picker.test.js.snap b/src/components/date_picker/super_date_picker/__snapshots__/super_date_picker.test.js.snap index 19e03cf7e6f7..481b5a7612fc 100644 --- a/src/components/date_picker/super_date_picker/__snapshots__/super_date_picker.test.js.snap +++ b/src/components/date_picker/super_date_picker/__snapshots__/super_date_picker.test.js.snap @@ -21,6 +21,7 @@ exports[`EuiSuperDatePicker is rendered 1`] = ` isLoading={false} prepend={ { + if (!this.isStopped) { + this.timeoutId = window.setTimeout(async () => { + this.__pendingFn = await fn(); + this.setAsyncInterval(fn, ms); + }, ms); + } + }; + + stop = () => { + this.isStopped = true; + window.clearTimeout(this.timeoutId); + }; +} diff --git a/src/components/date_picker/super_date_picker/async_interval.test.js b/src/components/date_picker/super_date_picker/async_interval.test.js new file mode 100644 index 000000000000..7b3eab69b22e --- /dev/null +++ b/src/components/date_picker/super_date_picker/async_interval.test.js @@ -0,0 +1,95 @@ +import { AsyncInterval } from './async_interval'; +import { times } from 'lodash'; + +describe('AsyncInterval', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // Advances time and awaits any pending promises after every 100ms + // This helper makes it easier to advance time without worrying + // whether tasks are still lingering on the event loop + async function andvanceTimerAndAwaitFn(instance, ms) { + const iterations = times(Math.floor(ms / 100)); + const remainder = ms % 100; + // eslint-disable-next-line no-unused-vars + for (const item of iterations) { + await instance.__pendingFn; + jest.advanceTimersByTime(100); + await instance.__pendingFn; + } + jest.advanceTimersByTime(remainder); + await instance.__pendingFn; + } + + describe('when creating a 1000ms interval', async () => { + let instance; + let spy; + beforeEach(() => { + spy = jest.fn(); + instance = new AsyncInterval(spy, 1000); + }); + + it('should not call fn immediately', async () => { + await andvanceTimerAndAwaitFn(instance, 0); + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('should have called fn once after 1000ms', async () => { + await andvanceTimerAndAwaitFn(instance, 1000); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should have called fn twice after 2000ms', async () => { + await andvanceTimerAndAwaitFn(instance, 2000); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should have called fn three times after 3000ms', async () => { + await andvanceTimerAndAwaitFn(instance, 3000); + expect(spy).toHaveBeenCalledTimes(3); + }); + + it('should not call fn after stop has been invoked', async () => { + await andvanceTimerAndAwaitFn(instance, 1000); + expect(spy).toHaveBeenCalledTimes(1); + instance.stop(); + await andvanceTimerAndAwaitFn(instance, 1000); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('when creating a 1000ms interval that calls a fn that takes 2000ms to complete', async () => { + let instance; + let spy; + beforeEach(() => { + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + spy = jest.fn(async () => await sleep(2000)); + instance = new AsyncInterval(spy, 1000); + }); + + it('should not call fn immediately', async () => { + await andvanceTimerAndAwaitFn(instance, 0); + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('should have called fn once after 1000ms', async () => { + await andvanceTimerAndAwaitFn(instance, 1000); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should have called fn twice after 4000ms', async () => { + await andvanceTimerAndAwaitFn(instance, 4000); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should have called fn tree times after 7000ms', async () => { + await andvanceTimerAndAwaitFn(instance, 7000); + expect(spy).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/src/components/date_picker/super_date_picker/super_date_picker.js b/src/components/date_picker/super_date_picker/super_date_picker.js index 22cdc670e7f8..99bdbd709b72 100644 --- a/src/components/date_picker/super_date_picker/super_date_picker.js +++ b/src/components/date_picker/super_date_picker/super_date_picker.js @@ -15,6 +15,7 @@ import { EuiDatePopoverButton } from './date_popover/date_popover_button'; import { EuiDatePickerRange } from '../date_picker_range'; import { EuiFormControlLayout } from '../../form'; import { EuiFlexGroup, EuiFlexItem } from '../../flex'; +import { AsyncInterval } from './async_interval'; function isRangeInvalid(start, end) { if (start === 'now' && end === 'now') { @@ -62,6 +63,14 @@ export class EuiSuperDatePicker extends Component { */ onRefreshChange: PropTypes.func, + /** + * Callback for when the refresh interval is fired. Called with { start, end, refreshInterval } + * EuiSuperDatePicker will only manage a refresh interval timer when onRefresh callback is supplied + * If a promise is returned, the next refresh interval will not start until the promise has resolved. + * If the promise rejects the refresh interval will stop and the error thrown + */ + onRefresh: PropTypes.func, + /** * 'start' and 'end' must be string as either datemath (e.g.: now, now-15m, now-15m/m) or * absolute date in the format 'YYYY-MM-DDTHH:mm:ss.sssZ' @@ -168,6 +177,16 @@ export class EuiSuperDatePicker extends Component { } } + componentDidMount = () => { + if(!this.props.isPaused) { + this.startInterval(this.props.refreshInterval); + } + } + + componentWillUnmount = () => { + this.stopInterval(); + } + setStart = (start) => { this.setTime({ start, end: this.state.end }); } @@ -221,6 +240,30 @@ export class EuiSuperDatePicker extends Component { this.setState({ isEndDatePopoverOpen: false }); } + onRefreshChange = ({ refreshInterval, isPaused }) => { + this.stopInterval(); + if(!isPaused) { + this.startInterval(refreshInterval); + } + if(this.props.onRefreshChange) { + this.props.onRefreshChange({ refreshInterval, isPaused }); + } + } + + stopInterval = () => { + if(this.asyncInterval) { + this.asyncInterval.stop(); + } + } + + startInterval = (refreshInterval) => { + const { start, end, onRefresh } = this.props; + if(onRefresh) { + const handler = () => onRefresh({ start, end, refreshInterval }); + this.asyncInterval = new AsyncInterval(handler, refreshInterval); + } + } + renderDatePickerRange = () => { const { start, @@ -329,7 +372,7 @@ export class EuiSuperDatePicker extends Component { applyTime={this.applyQuickTime} start={this.props.start} end={this.props.end} - applyRefreshInterval={this.props.onRefreshChange} + applyRefreshInterval={this.props.onRefreshChange ? this.onRefreshChange : null} isPaused={this.props.isPaused} refreshInterval={this.props.refreshInterval} commonlyUsedRanges={this.props.commonlyUsedRanges} diff --git a/src/components/form/range/__snapshots__/dual_range.test.js.snap b/src/components/form/range/__snapshots__/dual_range.test.js.snap index 1ee613624ecd..e2f8b4f92bea 100644 --- a/src/components/form/range/__snapshots__/dual_range.test.js.snap +++ b/src/components/form/range/__snapshots__/dual_range.test.js.snap @@ -1,5 +1,25 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`EuiDualRange allows value prop to accept empty strings 1`] = ` +
+
+ +
+
+`; + exports[`EuiDualRange allows value prop to accept numbers 1`] = `
@@ -20,7 +41,7 @@ exports[`EuiDualRange allows value prop to accept numbers 1`] = ` >
@@ -29,7 +50,7 @@ exports[`EuiDualRange allows value prop to accept numbers 1`] = ` exports[`EuiDualRange is rendered 1`] = `
+`; + +exports[`EuiDualRange props custom ticks should render 1`] = ` +
+
+ +
+
+
+
+ + +
+
+
+`; + +exports[`EuiDualRange props disabled should render 1`] = ` +
+
+ @@ -94,9 +199,10 @@ exports[`EuiDualRange props fullWidth should render 1`] = ` > @@ -114,7 +220,7 @@ exports[`EuiDualRange props fullWidth should render 1`] = ` exports[`EuiDualRange props inputs should render 1`] = `
@@ -140,12 +247,13 @@ exports[`EuiDualRange props inputs should render 1`] = ` @@ -170,7 +278,8 @@ exports[`EuiDualRange props inputs should render 1`] = ` max="10" min="1" name="name-maxValue" - style="width:4em" + step="1" + style="width:3.6em" type="number" value="8" /> @@ -186,16 +295,17 @@ exports[`EuiDualRange props labels should render 1`] = `
@@ -225,9 +335,10 @@ exports[`EuiDualRange props levels should render 1`] = ` > @@ -244,11 +355,11 @@ exports[`EuiDualRange props levels should render 1`] = ` >
@@ -264,9 +375,10 @@ exports[`EuiDualRange props range should render 1`] = ` > @@ -275,7 +387,7 @@ exports[`EuiDualRange props range should render 1`] = ` >
@@ -288,12 +400,14 @@ exports[`EuiDualRange props ticks should render 1`] = ` >
@@ -307,52 +421,67 @@ exports[`EuiDualRange props ticks should render 1`] = `
+
diff --git a/src/components/form/range/__snapshots__/range.test.js.snap b/src/components/form/range/__snapshots__/range.test.js.snap index 2c23ede4230c..ccdc42385762 100644 --- a/src/components/form/range/__snapshots__/range.test.js.snap +++ b/src/components/form/range/__snapshots__/range.test.js.snap @@ -10,7 +10,8 @@ exports[`EuiRange allows value prop to accept a number 1`] = ` @@ -19,7 +20,7 @@ exports[`EuiRange allows value prop to accept a number 1`] = ` > 8 @@ -28,21 +29,41 @@ exports[`EuiRange allows value prop to accept a number 1`] = `
`; -exports[`EuiRange is rendered 1`] = ` +exports[`EuiRange allows value prop to accept empty string 1`] = `
+
+ +
+
+`; + +exports[`EuiRange is rendered 1`] = ` +
@@ -60,28 +81,110 @@ exports[`EuiRange props compressed should render 1`] = ` +
+
+`; + +exports[`EuiRange props custom ticks should render 1`] = ` +
+
+ +
+ + +
`; -exports[`EuiRange props extra input should render 1`] = ` +exports[`EuiRange props disabled should render 1`] = `
+
+ +
+
+`; + +exports[`EuiRange props fullWidth should render 1`] = ` +
+
+ +
+
+`; + +exports[`EuiRange props input should render 1`] = ` +
@@ -109,23 +213,6 @@ exports[`EuiRange props extra input should render 1`] = `
`; -exports[`EuiRange props fullWidth should render 1`] = ` -
-
- -
-
-`; - exports[`EuiRange props labels should render 1`] = `
- 1 + 0
@@ -163,7 +251,8 @@ exports[`EuiRange props levels should render 1`] = `
@@ -190,9 +279,10 @@ exports[`EuiRange props range should render 1`] = ` class="euiRangeTrack" > @@ -201,7 +291,7 @@ exports[`EuiRange props range should render 1`] = ` >
@@ -214,61 +304,78 @@ exports[`EuiRange props ticks should render 1`] = ` >
+
@@ -285,16 +392,20 @@ exports[`EuiRange props value should render 1`] = `
+ style="right:0%" + > + before200after +
diff --git a/src/components/form/range/__snapshots__/range_levels.test.js.snap b/src/components/form/range/__snapshots__/range_levels.test.js.snap new file mode 100644 index 000000000000..c80640187187 --- /dev/null +++ b/src/components/form/range/__snapshots__/range_levels.test.js.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiRangeLevels is rendered 1`] = ` +
+ + +
+`; + +exports[`EuiRangeLevels should throw error if \`level.max\` is higher than \`max\` 1`] = `"The level max of 200 is higher than the max value of 100."`; + +exports[`EuiRangeLevels should throw error if \`level.min\` is lower than \`min\` 1`] = `"The level min of -10 is lower than the min value of 0."`; diff --git a/src/components/form/range/__snapshots__/range_track.test.js.snap b/src/components/form/range/__snapshots__/range_track.test.js.snap new file mode 100644 index 000000000000..9cb1a9135cb7 --- /dev/null +++ b/src/components/form/range/__snapshots__/range_track.test.js.snap @@ -0,0 +1,136 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiRangeTrack is rendered 1`] = ` +
+
+ + + + + + + + + + + +
+
+`; + +exports[`EuiRangeTrack should throw error if \`max\` does not line up with \`step\` interval 1`] = `"The value of 105 is not included in the possible sequence provided by the step of 10."`; + +exports[`EuiRangeTrack should throw error if \`tickInterval\` is off sequence from \`step\` 1`] = `"The value of 3 is not included in the possible sequence provided by the step of 10."`; + +exports[`EuiRangeTrack should throw error if custom tick value is higher than \`max\` 1`] = `"The value of 200 is higher than the max value of 100."`; + +exports[`EuiRangeTrack should throw error if custom tick value is lower than \`min\` 1`] = `"The value of -100 is lower than the min value of 0."`; + +exports[`EuiRangeTrack should throw error if custom tick value is off sequence from \`step\` 1`] = `"The value of 10 is not included in the possible sequence provided by the step of 50."`; + +exports[`EuiRangeTrack should throw error if there are too many ticks to render 1`] = `"The number of ticks to render is too high (22), reduce the interval."`; diff --git a/src/components/form/range/_range_input.scss b/src/components/form/range/_range_input.scss index eb3addf4fcda..a67a257fa380 100644 --- a/src/components/form/range/_range_input.scss +++ b/src/components/form/range/_range_input.scss @@ -1,11 +1,6 @@ -/* - * 1. Align extra input slightly better with slider labels, in an IE compliant way. - */ - .euiRangeInput { width: auto; - position: relative; /* 1 */ - top: -2px; /* 1 */ + min-width: $euiSize * 4; &--min { margin-right: $euiSize; diff --git a/src/components/form/range/_range_slider.scss b/src/components/form/range/_range_slider.scss index e35714484f9c..8b867b851adc 100644 --- a/src/components/form/range/_range_slider.scss +++ b/src/components/form/range/_range_slider.scss @@ -32,23 +32,25 @@ background-color: $euiRangeThumbBorderColor; box-shadow: none; } + + ~ .euiRangeThumb { + cursor: not-allowed; + border-color: $euiRangeThumbBorderColor; + background-color: $euiRangeThumbBorderColor; + box-shadow: none; + } } @include euiRangeThumbPerBrowser { @include euiCustomControl($type: 'round'); - @include euiRangeThumbStyle; } @include euiRangeTrackPerBrowser { @include euiRangeTrackSize; - background: $euiRangeTrackColor; border: $euiRangeTrackBorderWidth solid $euiRangeTrackBorderColor; border-radius: $euiRangeTrackRadius; - - background-color: transparentize($euiRangeTrackColor, .6); - border-color: transparentize($euiRangeTrackBorderColor, .6); } &:focus, @@ -57,6 +59,10 @@ @include euiCustomControlFocused; } + ~ .euiRangeThumb { + border-color: $euiColorPrimary; + } + @include euiRangeTrackPerBrowser { background-color: $euiColorPrimary; border-color: $euiColorPrimary; @@ -69,12 +75,9 @@ ~ .euiRangeTooltip .euiRangeTooltip__value { @include euiBottomShadowMedium; - &.euiRangeTooltip__value--right { - transform: translateX(0) translateY(-50%) scale(1.1); - } - + &.euiRangeTooltip__value--right, &.euiRangeTooltip__value--left { - transform: translateX(-100%) translateY(-50%) scale(1.1); + transform: translateX(0) translateY(-50%) scale(1.1); } } } @@ -94,7 +97,6 @@ &::-ms-track { @include euiRangeTrackSize; - background: transparent; border-color: transparent; border-width: ($euiRangeThumbHeight / 2) 0; @@ -106,3 +108,11 @@ height: $euiFormControlHeight / 2; // Adjust vertical alignment based on extras } } + +// Lighten the track when showing the range +.euiRangeSlider--hasRange { + @include euiRangeTrackPerBrowser { + background-color: transparentize($euiRangeTrackColor, .6); + border-color: transparentize($euiRangeTrackBorderColor, .6); + } +} diff --git a/src/components/form/range/_range_tooltip.scss b/src/components/form/range/_range_tooltip.scss index ebbe92dae4e2..0c55319e23d3 100644 --- a/src/components/form/range/_range_tooltip.scss +++ b/src/components/form/range/_range_tooltip.scss @@ -49,14 +49,11 @@ // Positions the arrow &.euiRangeTooltip__value--right { - transform: translateX(0) translateY(-50%); margin-left: $euiSizeL; &:before, &:after { - bottom: 50%; left: $arrowMinusSize; - transform: translateY(50%) rotateZ(45deg); } &::before { @@ -65,15 +62,12 @@ } &.euiRangeTooltip__value--left { - transform: translateX(-100%) translateY(-50%); - margin-left: -$euiSizeL; + margin-right: $euiSizeL; &:before, &:after { - bottom: 50%; left: auto; right: $arrowMinusSize; - transform: translateY(50%) rotateZ(45deg); } &::before { @@ -81,6 +75,17 @@ } } + &.euiRangeTooltip__value--right, + &.euiRangeTooltip__value--left { + transform: translateX(0) translateY(-50%); + + &:before, + &:after { + bottom: 50%; + transform: translateY(50%) rotateZ(45deg); + } + } + &--hasTicks { top: ($euiFormControlHeight / 4) - 1px; } diff --git a/src/components/form/range/dual_range.js b/src/components/form/range/dual_range.js index 3e6ab5026b2a..414d1a6d6d25 100644 --- a/src/components/form/range/dual_range.js +++ b/src/components/form/range/dual_range.js @@ -17,7 +17,6 @@ export class EuiDualRange extends Component { state = { hasFocus: false, rangeSliderRefAvailable: false, - lastThumbInteraction: null } rangeSliderRef = null; @@ -34,61 +33,39 @@ export class EuiDualRange extends Component { get upperValue() { return this.props.value ? this.props.value[1] : this.props.max; } + get lowerValueIsValid() { + return isWithinRange(this.props.min, this.upperValue, this.lowerValue); + } + get upperValueIsValid() { + return isWithinRange(this.lowerValue, this.props.max, this.upperValue); + } get isValid() { - return isWithinRange(this.props.min, this.upperValue, this.lowerValue) - && isWithinRange(this.lowerValue, this.props.max, this.upperValue); + return this.lowerValueIsValid && this.upperValueIsValid; } _determineInvalidThumbMovement = (newVal, lower, upper, e) => { - const isBackwards = Number(lower) >= Number(upper); - const isUnbound = Number(upper) < this.props.min || Number(lower) > this.props.max; - const isLow = lower < this.props.min; - const isHigh = upper > this.props.max; - if (isBackwards || isUnbound) { - // Scenerio in which we cannot reasonably infer intention via click location due to current invalid thumb positions. - // Reset both values in the proximity of the click. - lower = newVal - (this.props.step || 1); - upper = newVal; + // If the values are invalid, find whether the new value is in the upper + // or lower half and move the appropriate handle to the new value, + // while the other handle gets moved to the opposite bound (if invalid) + const lowerHalf = (Math.abs(this.props.max - this.props.min) / 2) + this.props.min; + const newValIsLow = isWithinRange(this.props.min, lowerHalf, newVal); + if (newValIsLow) { + lower = newVal; + upper = !this.upperValueIsValid ? this.props.max : upper; } else { - // Scenerio in which we can reasonably infer intention via click location if range extrema are respected. - // Reset either value to its respective terminal value. - lower = isLow ? this.props.min : lower; - upper = isHigh ? this.props.max : upper; + lower = !this.lowerValueIsValid ? this.props.min : lower; + upper = newVal; } this._handleOnChange(lower, upper, e); } _determineValidThumbMovement = (newVal, lower, upper, e) => { - const thumbsAreEquidistant = Math.abs(lower - newVal) === Math.abs(upper - newVal); - // Lower thumb nearing swap with upper thumb - if ( - (newVal === upper || (newVal < upper && thumbsAreEquidistant)) - && this.state.lastThumbInteraction === 'lower' - ) { - lower = newVal; - } - // Upper thumb nearing swap with lower thumb - else if ( - (newVal === lower || (newVal > lower && thumbsAreEquidistant)) - && this.state.lastThumbInteraction === 'upper' - ) { - upper = newVal; - } // Lower thumb targeted or right-moving swap has occured - else if ( - Math.abs(lower - newVal) < Math.abs(upper - newVal) - || (thumbsAreEquidistant && this.state.lastThumbInteraction === 'upper') - ) { - this.setState({ - lastThumbInteraction: 'lower' - }); + if (Math.abs(lower - newVal) < Math.abs(upper - newVal)) { lower = newVal; } // Upper thumb targeted or left-moving swap has occured else { - this.setState({ - lastThumbInteraction: 'upper' - }); upper = newVal; } this._handleOnChange(lower, upper, e); @@ -114,6 +91,23 @@ export class EuiDualRange extends Component { this._determineThumbMovement(e.target.value, e); } + _resetToRangeEnds = (e) => { + // Arbitrary decision to pass `min` instead of `max`. Result is the same. + this._determineInvalidThumbMovement(this.props.min, this.lowerValue, this.upperValue, e); + } + + _isDirectionalKeyPress = (e) => { + return [keyCodes.UP, keyCodes.RIGHT, keyCodes.DOWN, keyCodes.LEFT].indexOf(e.keyCode) > -1; + } + + handleInputKeyDown = (e) => { + // Relevant only when initial values are both `''` and `showInput` is set + if (this._isDirectionalKeyPress(e) && !this.isValid) { + e.preventDefault(); + this._resetToRangeEnds(e); + } + } + handleLowerInputChange = (e) => { this._handleOnChange(e.target.value, this.upperValue, e); } @@ -155,6 +149,12 @@ export class EuiDualRange extends Component { case keyCodes.TAB: return; default: + if (!this.lowerValueIsValid) { + // Relevant only when initial value is `''` and `showInput` is not set + e.preventDefault(); + this._resetToRangeEnds(e); + return; + } lower = this._handleKeyDown(lower, e); } if (lower >= this.upperValue || lower < this.props.min) return; @@ -167,6 +167,12 @@ export class EuiDualRange extends Component { case keyCodes.TAB: return; default: + if (!this.upperValueIsValid) { + // Relevant only when initial value is `''` and `showInput` is not set + e.preventDefault(); + this._resetToRangeEnds(e); + return; + } upper = this._handleKeyDown(upper, e); } if (upper <= this.lowerValue || upper > this.props.max) return; @@ -208,7 +214,7 @@ export class EuiDualRange extends Component { showInput, showTicks, tickInterval, - ticks, // eslint-disable-line no-unused-vars + ticks, levels, onChange, // eslint-disable-line no-unused-vars showRange, @@ -217,24 +223,26 @@ export class EuiDualRange extends Component { ...rest } = this.props; - const sliderClasses = classNames('euiDualRange__slider', className); + const classes = classNames('euiDualRange', className); + const digitTolerance = Math.max(String(min).length, String(max).length); return ( {showInput && ( @@ -283,7 +292,7 @@ export class EuiDualRange extends Component { onKeyDown={this.handleLowerKeyDown} onFocus={() => this.toggleHasFocus(true)} onBlur={() => this.toggleHasFocus(false)} - style={this.calculateThumbPositionStyle(this.lowerValue)} + style={this.calculateThumbPositionStyle(this.lowerValue || min)} aria-describedby={this.props['aria-describedby']} aria-label={this.props['aria-label']} /> @@ -297,7 +306,7 @@ export class EuiDualRange extends Component { onKeyDown={this.handleUpperKeyDown} onFocus={() => this.toggleHasFocus(true)} onBlur={() => this.toggleHasFocus(false)} - style={this.calculateThumbPositionStyle(this.upperValue)} + style={this.calculateThumbPositionStyle(this.upperValue || max)} aria-describedby={this.props['aria-describedby']} aria-label={this.props['aria-label']} /> @@ -318,14 +327,16 @@ export class EuiDualRange extends Component { {showLabels && {max}} {showInput && ( { }); describe('props', () => { + test('disabled should render', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + test('fullWidth should render', () => { const component = render( @@ -59,6 +68,21 @@ describe('EuiDualRange', () => { .toMatchSnapshot(); }); + test('custom ticks should render', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + test('range should render', () => { const component = render( @@ -92,12 +116,12 @@ describe('EuiDualRange', () => { levels={[ { min: 0, - max: 600, + max: 20, color: 'danger' }, { - min: 600, - max: 2000, + min: 20, + max: 100, color: 'success' } ]} @@ -120,4 +144,16 @@ describe('EuiDualRange', () => { expect(component) .toMatchSnapshot(); }); + + test('allows value prop to accept empty strings', () => { + const component = render( + {}} + /> + ); + + expect(component) + .toMatchSnapshot(); + }); }); diff --git a/src/components/form/range/range.js b/src/components/form/range/range.js index 9dad9a7dc6b9..c3123fb8e55a 100644 --- a/src/components/form/range/range.js +++ b/src/components/form/range/range.js @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; import { isWithinRange } from '../../../services/number'; @@ -42,6 +43,7 @@ export class EuiRange extends Component { showRange, showValue, valueAppend, // eslint-disable-line no-unused-vars + valuePrepend, // eslint-disable-line no-unused-vars onChange, // eslint-disable-line no-unused-vars value, style, @@ -49,9 +51,12 @@ export class EuiRange extends Component { ...rest } = this.props; + const classes = classNames('euiRange', className); + const digitTolerance = Math.max(String(min).length, String(max).length); + return ( {showLabels && {min}} @@ -70,7 +75,6 @@ export class EuiRange extends Component { @@ -90,6 +95,8 @@ export class EuiRange extends Component { min={min} name={name} showTicks={showTicks} + valuePrepend={valuePrepend} + valueAppend={valueAppend} /> )} @@ -108,6 +115,7 @@ export class EuiRange extends Component { { }); describe('props', () => { + test('disabled should render', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + test('fullWidth should render', () => { const component = render( @@ -59,6 +68,21 @@ describe('EuiRange', () => { .toMatchSnapshot(); }); + test('custom ticks should render', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + test('range should render', () => { const component = render( @@ -70,14 +94,19 @@ describe('EuiRange', () => { test('value should render', () => { const component = render( - + ); expect(component) .toMatchSnapshot(); }); - test('extra input should render', () => { + test('input should render', () => { const component = render( { levels={[ { min: 0, - max: 600, + max: 20, color: 'danger' }, { - min: 600, - max: 2000, + min: 20, + max: 100, color: 'success' } ]} @@ -130,4 +159,16 @@ describe('EuiRange', () => { expect(component) .toMatchSnapshot(); }); + + test('allows value prop to accept empty string', () => { + const component = render( + {}} + /> + ); + + expect(component) + .toMatchSnapshot(); + }); }); diff --git a/src/components/form/range/range_input.js b/src/components/form/range/range_input.js index c748b95baf1a..273ca32606a9 100644 --- a/src/components/form/range/range_input.js +++ b/src/components/form/range/range_input.js @@ -13,15 +13,14 @@ export const EuiRangeInput = ({ onChange, name, side, - digits, + digitTolerance, ...rest }) => { // Chrome will properly size the input based on the max value, but FF & IE do not. // Calculate the width of the input based on highest number of characters. // Add 2 to accomodate for input stepper - const digitTolerance = !!digits ? digits : Math.max(String(min).length, String(max).length); - const widthStyle = { width: `${digitTolerance + 2}em` }; + const widthStyle = { width: `${(digitTolerance / 1.25) + 2}em` }; return ( { + const validateLevelIsInRange = (level) => { + if (level.min < min) { + throw new Error(`The level min of ${level.min} is lower than the min value of ${min}.`); + } + if (level.max > max) { + throw new Error(`The level max of ${level.max} is higher than the max value of ${max}.`); + } + }; + const classes = classNames('euiRangeLevels', { 'euiRangeLevels--hasTicks': showTicks }); + return (
{levels.map((level, index) => { + validateLevelIsInRange(level); const range = level.max - level.min; const width = (range / (max - min)) * 100; @@ -25,9 +36,9 @@ export const EuiRangeLevels = ({ levels, max, min, showTicks }) => { EuiRangeLevels.propTypes = { levels: PropTypes.arrayOf( PropTypes.shape({ - min: PropTypes.number, - max: PropTypes.number, - color: PropTypes.oneOf(LEVEL_COLORS), + min: PropTypes.number.isRequired, + max: PropTypes.number.isRequired, + color: PropTypes.oneOf(LEVEL_COLORS).isRequired, }), ), max: PropTypes.number.isRequired, diff --git a/src/components/form/range/range_levels.test.js b/src/components/form/range/range_levels.test.js new file mode 100644 index 000000000000..b39777a2d2ed --- /dev/null +++ b/src/components/form/range/range_levels.test.js @@ -0,0 +1,69 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiRangeLevels } from './range_levels'; + +describe('EuiRangeLevels', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('should throw error if `level.min` is lower than `min`', () => { + const component = () => render( + + ); + + expect(component).toThrowErrorMatchingSnapshot(); + }); + + test('should throw error if `level.max` is higher than `max`', () => { + const component = () => render( + + ); + + expect(component).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/components/form/range/range_slider.js b/src/components/form/range/range_slider.js index cba60615ec7a..c33757295efe 100644 --- a/src/components/form/range/range_slider.js +++ b/src/components/form/range/range_slider.js @@ -15,12 +15,14 @@ export const EuiRangeSlider = React.forwardRef(({ value, style, showTicks, + showRange, hasFocus, ...rest }, ref) => { const classes = classNames('euiRangeSlider', { 'euiRangeSlider--hasTicks': showTicks, - 'euiRangeSlider--hasFocus': hasFocus + 'euiRangeSlider--hasFocus': hasFocus, + 'euiRangeSlider--hasRange': showRange, }, className); return ( { +export const EuiRangeTicks = ({ + disabled, + onChange, + ticks, + tickSequence, + value, + max, + min, + interval, +}) => { + // Calculate the width of each tick mark + const percentageWidth = (interval / ((max - min) + interval)) * 100; + // Align with item labels across the range by adding // left and right negative margins that is half of the tick marks - const ticksStyle = !!ticks ? undefined : { margin: `0 ${tickObject.percentageWidth / -2}%`, left: 0, right: 0 }; + const ticksStyle = !!ticks + ? undefined + : { margin: `0 ${percentageWidth / -2}%`, left: 0, right: 0 }; return (
- {tickObject.sequence.map((tickValue) => { + {tickSequence.map(tickValue => { const tickStyle = {}; let customTick; if (ticks) { customTick = find(ticks, o => o.value === tickValue); - if (customTick == null) { - return; - } else { - tickStyle.left = `${(customTick.value / max) * 100}%`; + if (customTick) { + tickStyle.left = `${((customTick.value - min) / (max - min)) * 100}%`; } } else { - tickStyle.width = `${tickObject.percentageWidth}%`; + tickStyle.width = `${percentageWidth}%`; } - const tickClasses = classNames( - 'euiRangeTick', - { - 'euiRangeTick--selected': value === tickValue, - 'euiRangeTick--isCustom': customTick, - } - ); + const tickClasses = classNames('euiRangeTick', { + 'euiRangeTick--selected': value === tickValue, + 'euiRangeTick--isCustom': customTick, + }); + + const label = customTick ? customTick.label : tickValue; return ( ); })} @@ -61,17 +72,17 @@ EuiRangeTicks.propTypes = { PropTypes.shape({ value: PropTypes.number.isRequired, label: PropTypes.node.isRequired, - }), + }) ), - tickObject: PropTypes.shape({ - decimalWidth: PropTypes.number, - percentageWidth: PropTypes.number, - sequence: PropTypes.arrayOf(PropTypes.number), - }).isRequired, + tickSequence: PropTypes.arrayOf(PropTypes.number).isRequired, value: PropTypes.oneOfType([ PropTypes.number, PropTypes.string, - PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])) + PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + ), ]), - max: PropTypes.number.isRequired + min: PropTypes.number.isRequired, + max: PropTypes.number.isRequired, + interval: PropTypes.number, }; diff --git a/src/components/form/range/range_tooltip.js b/src/components/form/range/range_tooltip.js index 78e66aaa45ce..a8dabf99cbb4 100644 --- a/src/components/form/range/range_tooltip.js +++ b/src/components/form/range/range_tooltip.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -export const EuiRangeTooltip = ({ value, valueAppend, max, min, name, showTicks }) => { +export const EuiRangeTooltip = ({ value, valueAppend, valuePrepend, max, min, name, showTicks }) => { // Calculate the left position based on value const decimal = (value - min) / (max - min); // Must be between 0-100% @@ -10,14 +10,15 @@ export const EuiRangeTooltip = ({ value, valueAppend, max, min, name, showTicks valuePosition = valuePosition >= 0 ? valuePosition : 0; let valuePositionSide; + let valuePositionStyle; if (valuePosition > .5) { valuePositionSide = 'left'; + valuePositionStyle = { right: `${(1 - valuePosition) * 100}%` }; } else { valuePositionSide = 'right'; + valuePositionStyle = { left: `${valuePosition * 100}%` }; } - const valuePositionStyle = { left: `${valuePosition * 100}%` }; - // Change left/right position based on value (half way point) const valueClasses = classNames( 'euiRangeTooltip__value', @@ -30,7 +31,7 @@ export const EuiRangeTooltip = ({ value, valueAppend, max, min, name, showTicks return (
- {value}{valueAppend} + {valuePrepend}{value}{valueAppend}
); @@ -38,7 +39,8 @@ export const EuiRangeTooltip = ({ value, valueAppend, max, min, name, showTicks EuiRangeTooltip.propTypes = { value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - valueAppend: PropTypes.string, + valueAppend: PropTypes.node, + valuePrepend: PropTypes.node, max: PropTypes.number.isRequired, min: PropTypes.number.isRequired, name: PropTypes.string diff --git a/src/components/form/range/range_track.js b/src/components/form/range/range_track.js index bdab431799d8..58f1f70a62f2 100644 --- a/src/components/form/range/range_track.js +++ b/src/components/form/range/range_track.js @@ -10,24 +10,55 @@ import { EuiRangeTicks } from './range_ticks'; export { LEVEL_COLORS }; export class EuiRangeTrack extends Component { + validateValueIsInStep = (value) => { + if (value < this.props.min) { + throw new Error(`The value of ${value} is lower than the min value of ${this.props.min}.`); + } + if (value > this.props.max) { + throw new Error(`The value of ${value} is higher than the max value of ${this.props.max}.`); + } + // Error out if the value doesn't line up with the sequence of steps + if ((value - this.props.min) % this.props.step > 0) { + throw new Error(`The value of ${value} is not included in the possible sequence provided by the step of ${this.props.step}.`); + } + // Return the value if nothing fails + return value; + } - calculateTicksObject = (min, max, interval) => { - // Calculate the width of each tick mark - const tickWidthDecimal = (interval / ((max - min) + interval)); - const tickWidthPercentage = tickWidthDecimal * 100; - // Loop from min to max, creating ticks at each interval + calculateSequence = (min, max, interval) => { + // Loop from min to max, creating adding values at each interval // (adds a very small number to the max since `range` is not inclusive of the max value) const toBeInclusive = .000000001; - const sequence = range(min, max + toBeInclusive, interval); + return range(min, max + toBeInclusive, interval); + } - return ( - { - decimalWidth: tickWidthDecimal, - percentageWidth: tickWidthPercentage, - sequence: sequence, - } - ); + calculateTicks = (min, max, step, tickInterval, customTicks) => { + let ticks; + + if (customTicks) { + // If custom values were passed, use those for the sequence + // But make sure they align with the possible sequence + ticks = customTicks.map(tick => { + return this.validateValueIsInStep(tick.value); + }); + } else { + // If a custom interval was passed, use those for the sequence + // But make sure they align with the possible sequence + const interval = tickInterval || step; + const tickSequence = this.calculateSequence(min, max, interval); + + ticks = tickSequence.map(tick => { + return this.validateValueIsInStep(tick); + }); + } + + // Error out if there are too many ticks to render + if (ticks.length > 20) { + throw new Error(`The number of ticks to render is too high (${ticks.length}), reduce the interval.`); + } + + return ticks; } render() { @@ -39,22 +70,25 @@ export class EuiRangeTrack extends Component { step, showTicks, tickInterval, - ticks, // eslint-disable-line no-unused-vars + ticks, levels, onChange, value } = this.props; - let tickObject; + // TODO: Move these to only re-calculate if no-value props have changed + this.validateValueIsInStep(max); + + let tickSequence; const inputWrapperStyle = {}; if (showTicks) { - tickObject = this.calculateTicksObject(min, max, tickInterval || step || 1); + tickSequence = this.calculateTicks(min, max, step, tickInterval, ticks); // Calculate if any extra margin should be added to the inputWrapper // because of longer tick labels on the ends - const lengthOfMinLabel = String(tickObject.sequence[0]).length; - const lenghtOfMaxLabel = String(tickObject.sequence[tickObject.sequence.length - 1]).length; - const isLastTickTheMax = tickObject.sequence[tickObject.sequence.length - 1] === max; + const lengthOfMinLabel = String(tickSequence[0]).length; + const lenghtOfMaxLabel = String(tickSequence[tickSequence.length - 1]).length; + const isLastTickTheMax = tickSequence[tickSequence.length - 1] === max; if (lengthOfMinLabel > 2) { inputWrapperStyle.marginLeft = `${(lengthOfMinLabel / 5)}em`; } @@ -70,7 +104,7 @@ export class EuiRangeTrack extends Component { return (
{children} - {!!levels.length && ( + {levels && !!levels.length && ( )} - {showTicks && ( + {tickSequence && ( )}
diff --git a/src/components/form/range/range_track.test.js b/src/components/form/range/range_track.test.js new file mode 100644 index 000000000000..95f450bd2203 --- /dev/null +++ b/src/components/form/range/range_track.test.js @@ -0,0 +1,108 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiRangeTrack } from './range_track'; + +describe('EuiRangeTrack', () => { + test('is rendered', () => { + const component = render( + {}} + {...requiredProps} + /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('should throw error if `max` does not line up with `step` interval', () => { + const component = () => render( + + ); + + expect(component).toThrowErrorMatchingSnapshot(); + }); + + test('should throw error if there are too many ticks to render', () => { + const component = () => render( + + ); + + expect(component).toThrowErrorMatchingSnapshot(); + }); + + test('should throw error if `tickInterval` is off sequence from `step`', () => { + const component = () => render( + + ); + + expect(component).toThrowErrorMatchingSnapshot(); + }); + + test('should throw error if custom tick value is lower than `min`', () => { + const component = () => render( + + ); + + expect(component).toThrowErrorMatchingSnapshot(); + }); + + test('should throw error if custom tick value is higher than `max`', () => { + const component = () => render( + + ); + + expect(component).toThrowErrorMatchingSnapshot(); + }); + + test('should throw error if custom tick value is off sequence from `step`', () => { + const component = () => render( + + ); + + expect(component).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/components/index.d.ts b/src/components/index.d.ts index becd49b4383d..6c91c3c9de34 100644 --- a/src/components/index.d.ts +++ b/src/components/index.d.ts @@ -7,6 +7,7 @@ /// /// /// +/// /// /// /// diff --git a/yarn.lock b/yarn.lock index a9753c111242..cd73bab33839 100644 --- a/yarn.lock +++ b/yarn.lock @@ -901,6 +901,15 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.3.tgz#bef071852dca2a2dbb65fecdb7bfb30cedae2de2" integrity sha512-sfjHrNF4zWRv3fJUGyZW46wVxhYJ/GeWIPdKxbnLIhY3bWR0Ncl2kIhZI7rpjY9KtUQAkDP8jWEmaGQGFFvruA== +"@types/react-datepicker@1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-1.8.0.tgz#b8b4f69d3e5398500c136f5338e0746ed98d46e8" + integrity sha512-QyHMOFCOFIkcyDCXUGnL7OyM+Gj2aG95d3t18wgrLTxQJseVQXeQFTVnUeXmmF2cZxeWtGTfRl1uYPTr3/rAFg== + dependencies: + "@types/react" "*" + moment ">=2.14.0" + popper.js "^1.14.1" + "@types/react-is@~16.3.0": version "16.3.1" resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-16.3.1.tgz#f3e1dee9d0eb58c049825540cb061b5588022a9e" @@ -8859,6 +8868,11 @@ moment@2.x.x: resolved "https://registry.yarnpkg.com/moment/-/moment-2.21.0.tgz#2a114b51d2a6ec9e6d83cf803f838a878d8a023a" integrity sha512-TCZ36BjURTeFTM/CwRcViQlfkMvL1/vFISuNLO5GkcVm1+QHfbSiNqZuWeMFjj1/3+uAjXswgRk30j1kkLYJBQ== +moment@>=2.14.0: + version "2.24.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" + integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== + moment@^2.20.1: version "2.20.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd" @@ -10159,6 +10173,11 @@ pngjs@~2.2.0: resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-2.2.0.tgz#649663609a0ebab87c8f08b3fe724048b51d9d7f" integrity sha1-ZJZjYJoOurh8jwiz/nJASLUdnX8= +popper.js@^1.14.1: + version "1.14.7" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.7.tgz#e31ec06cfac6a97a53280c3e55e4e0c860e7738e" + integrity sha512-4q1hNvoUre/8srWsH7hnoSJ5xVmIL4qgz+s4qf2TnJIMyZFUFMGH+9vE7mXynAlHSZ/NdTmmow86muD0myUkVQ== + portfinder@^1.0.9: version "1.0.13" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.13.tgz#bb32ecd87c27104ae6ee44b5a3ccbf0ebb1aede9"