diff --git a/.eslintrc.js b/.eslintrc.js index 14770614e0..9cb8514086 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -69,6 +69,7 @@ module.exports = { '@typescript-eslint/ban-ts-ignore': 'off', '@typescript-eslint/no-inferrable-types': 'off', 'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'never' }], + 'react/prop-types': 0, }, settings: { 'import/resolver': { diff --git a/.storybook/style.scss b/.storybook/style.scss index f7df75d346..8d9d656642 100644 --- a/.storybook/style.scss +++ b/.storybook/style.scss @@ -1,3 +1,5 @@ +@import '../node_modules/@elastic/eui/src/theme_light.scss'; + .story-chart { box-sizing: border-box; background: white; diff --git a/integration/jest.config.js b/integration/jest.config.js index cb02bbd111..1b2ce938fe 100644 --- a/integration/jest.config.js +++ b/integration/jest.config.js @@ -8,6 +8,15 @@ module.exports = Object.assign( 'ts-jest': { tsConfig: '/tsconfig.json', }, + /* + * The window and HTMLElement globals are required to use @elastic/eui with VRT + * + * The jest-puppeteer-docker env extends a node test environment and not jsdom test environment. + * Some EUI components that are included in the bundle, but not used, require the jsdom setup. + * To bypass these errors we are just mocking both as empty objects. + */ + window: {}, + HTMLElement: {}, }, }, jestPuppeteerDocker, diff --git a/integration/page_objects/common.ts b/integration/page_objects/common.ts index 9f0669a073..15b69cc122 100644 --- a/integration/page_objects/common.ts +++ b/integration/page_objects/common.ts @@ -15,13 +15,48 @@ interface ScreenshotDOMElementOptions { path?: string; } +type ScreenshotElementAtUrlOptions = ScreenshotDOMElementOptions & { + /** + * timeout for waiting on element to appear in DOM + * + * @default JEST_TIMEOUT + */ + timeout?: number; + /** + * any desired action to be performed after loading url, prior to screenshot + */ + action?: () => void | Promise; + /** + * Selector used to wait on DOM element + */ + waitSelector?: string; + /** + * Delay to take screenshot after element is visiable + */ + delay?: number; +}; + class CommonPage { + readonly chartWaitSelector = '.echChartStatus[data-ech-render-complete=true]'; + readonly chartSelector = '.echChart'; + + /** + * Parse url from knob storybook url to iframe storybook url + * + * @param url + */ static parseUrl(url: string): string { const { query } = Url.parse(url); return `${baseUrl}?${query}${query ? '&' : ''}knob-debug=false`; } - async getBoundingClientRect(selector = '.echChart') { + + /** + * Get getBoundingClientRect of selected element + * + * @param selector + */ + async getBoundingClientRect(selector: string) { return await page.evaluate((selector) => { const element = document.querySelector(selector); @@ -34,12 +69,16 @@ class CommonPage { return { left: x, top: y, width, height, id: element.id }; }, selector); } + /** - * Capture screenshot or chart element only + * Capture screenshot of selected element only + * + * @param selector + * @param options */ - async screenshotDOMElement(selector = '.echChart', opts?: ScreenshotDOMElementOptions) { - const padding: number = opts && opts.padding ? opts.padding : 0; - const path: string | undefined = opts && opts.path ? opts.path : undefined; + async screenshotDOMElement(selector: string, options?: ScreenshotDOMElementOptions) { + const padding: number = options && options.padding ? options.padding : 0; + const path: string | undefined = options && options.path ? options.path : undefined; const rect = await this.getBoundingClientRect(selector); return page.screenshot({ @@ -53,69 +92,121 @@ class CommonPage { }); } - async moveMouseRelativeToDOMElement(mousePosition: { x: number; y: number }, selector = '.echChart') { - const chartContainer = await this.getBoundingClientRect(selector); - await page.mouse.move(chartContainer.left + mousePosition.x, chartContainer.top + mousePosition.y); + /** + * Move mouse relative to element + * + * @param mousePosition + * @param selector + */ + async moveMouseRelativeToDOMElement(mousePosition: { x: number; y: number }, selector: string) { + const element = await this.getBoundingClientRect(selector); + await page.mouse.move(element.left + mousePosition.x, element.top + mousePosition.y); + } + + /** + * Click mouse relative to element + * + * @param mousePosition + * @param selector + */ + async clickMouseRelativeToDOMElement(mousePosition: { x: number; y: number }, selector: string) { + const element = await this.getBoundingClientRect(selector); + await page.mouse.click(element.left + mousePosition.x, element.top + mousePosition.y); } /** - * Expect a chart given a url from storybook. + * Expect an element given a url and selector from storybook * * - Note: No need to fix host or port. They will be set automatically. * * @param url Storybook url from knobs section + * @param selector selector of element to screenshot + * @param options */ - async expectChartAtUrlToMatchScreenshot(url: string) { + async expectElementAtUrlToMatchScreenshot( + url: string, + selector: string = 'body', + options?: ScreenshotElementAtUrlOptions, + ) { try { - await this.loadChartFromURL(url); - await this.waitForElement(); + await this.loadElementFromURL(url, options?.waitSelector ?? selector, options?.timeout); - const chart = await this.screenshotDOMElement(); + if (options?.action) { + await options.action(); + } - if (!chart) { - throw new Error(`Error: Unable to find chart element\n\n\t${url}`); + if (options?.delay) { + await page.waitFor(options.delay); } - expect(chart).toMatchImageSnapshot(); + const element = await this.screenshotDOMElement(selector, options); + + if (!element) { + throw new Error(`Error: Unable to find element\n\n\t${url}`); + } + + expect(element).toMatchImageSnapshot(); } catch (error) { throw new Error(error); } } /** - * Expect a chart given a url from storybook. - * - * - Note: No need to fix host or port. They will be set automatically. + * Expect a chart given a url from storybook * * @param url Storybook url from knobs section + * @param options */ - async expectChartWithMouseAtUrlToMatchScreenshot(url: string, mousePosition: { x: number; y: number }) { - try { - await this.loadChartFromURL(url); - await this.waitForElement(); - await this.moveMouseRelativeToDOMElement(mousePosition); - const chart = await this.screenshotDOMElement(); - if (!chart) { - throw new Error(`Error: Unable to find chart element\n\n\t${url}`); - } + async expectChartAtUrlToMatchScreenshot(url: string, options?: ScreenshotElementAtUrlOptions) { + await this.expectElementAtUrlToMatchScreenshot(url, this.chartSelector, { + waitSelector: this.chartWaitSelector, + ...options, + }); + } - expect(chart).toMatchImageSnapshot(); - } catch (error) { - throw new Error(`${error}\n\n${url}`); - } + /** + * Expect a chart given a url from storybook with mouse move + * + * @param url Storybook url from knobs section + * @param mousePosition - postion of mouse relative to chart + * @param options + */ + async expectChartWithMouseAtUrlToMatchScreenshot( + url: string, + mousePosition: { x: number; y: number }, + options?: Omit, + ) { + const action = async () => await this.moveMouseRelativeToDOMElement(mousePosition, this.chartSelector); + await this.expectChartAtUrlToMatchScreenshot(url, { + ...options, + action, + }); } - async loadChartFromURL(url: string) { + + /** + * Loads storybook page from raw url, and waits for element + * + * @param url Storybook url from knobs section + * @param waitSelector selector of element to wait to appear in DOM + * @param timeout timeout for waiting on element to appear in DOM + */ + async loadElementFromURL(url: string, waitSelector?: string, timeout?: number) { const cleanUrl = CommonPage.parseUrl(url); await page.goto(cleanUrl); - this.waitForElement(); + + if (waitSelector) { + await this.waitForElement(waitSelector, timeout); + } } + /** * Wait for an element to be on the DOM - * @param {string} [selector] the DOM selector to wait for, default to '.echChartStatus[data-ech-render-complete=true]' + * + * @param {string} [waitSelector] the DOM selector to wait for, default to '.echChartStatus[data-ech-render-complete=true]' * @param {number} [timeout] - the timeout for the operation, default to 10000ms */ - async waitForElement(selector = '.echChartStatus[data-ech-render-complete=true]', timeout = JEST_TIMEOUT) { - await page.waitForSelector(selector, { timeout }); + async waitForElement(waitSelector: string, timeout = JEST_TIMEOUT) { + await page.waitForSelector(waitSelector, { timeout }); } } diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-legend-color-picker-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-legend-color-picker-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..98c49da8de Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-legend-color-picker-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-render-color-picker-on-mouse-click-1-snap.png b/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-render-color-picker-on-mouse-click-1-snap.png new file mode 100644 index 0000000000..5dc72e3201 Binary files /dev/null and b/integration/tests/__image_snapshots__/legend-stories-test-ts-legend-stories-should-render-color-picker-on-mouse-click-1-snap.png differ diff --git a/integration/tests/legend_stories.test.ts b/integration/tests/legend_stories.test.ts index 0c40236201..a2b76b89ef 100644 --- a/integration/tests/legend_stories.test.ts +++ b/integration/tests/legend_stories.test.ts @@ -21,4 +21,17 @@ describe('Legend stories', () => { 'http://localhost:9001/?path=/story/legend--legend-spacing-buffer&knob-legend buffer value=0', ); }); + + it('should render color picker on mouse click', async () => { + const action = async () => await common.clickMouseRelativeToDOMElement({ x: 0, y: 0 }, '.echLegendItem__color'); + await common.expectElementAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/legend--color-picker', + 'body', + { + action, + waitSelector: common.chartWaitSelector, + delay: 500, // needed for popover animation to complete + }, + ); + }); }); diff --git a/package.json b/package.json index 0d035eb95c..27d8bd0b8e 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@babel/preset-react": "^7.8.3", "@commitlint/cli": "^8.1.0", "@commitlint/config-conventional": "^8.1.0", + "@elastic/datemath": "^5.0.2", "@elastic/eui": "^16.0.1", "@mdx-js/loader": "^1.5.5", "@semantic-release/changelog": "^3.0.6", diff --git a/scripts/setup_enzyme.ts b/scripts/setup_enzyme.ts index 82edfc9e5a..7435d8602f 100644 --- a/scripts/setup_enzyme.ts +++ b/scripts/setup_enzyme.ts @@ -2,3 +2,5 @@ import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; configure({ adapter: new Adapter() }); + +process.env.RNG_SEED = 'jest-unit-tests'; diff --git a/src/chart_types/partition_chart/layout/utils/calcs.ts b/src/chart_types/partition_chart/layout/utils/calcs.ts index d3f1ce6861..355c0ed371 100644 --- a/src/chart_types/partition_chart/layout/utils/calcs.ts +++ b/src/chart_types/partition_chart/layout/utils/calcs.ts @@ -1,5 +1,6 @@ import { Ratio } from '../types/geometry_types'; import { RgbTuple, stringToRGB } from './d3_utils'; +import { Color } from '../../../../utils/commons'; export function hueInterpolator(colors: RgbTuple[]) { return (d: number) => { @@ -26,7 +27,7 @@ export function arrayToLookup(keyFun: Function, array: Array) { return Object.assign({}, ...array.map((d) => ({ [keyFun(d)]: d }))); } -export function colorIsDark(color: string) { +export function colorIsDark(color: Color) { // fixme this assumes a white or very light background const rgba = stringToRGB(color); const { r, g, b, opacity } = rgba; diff --git a/src/chart_types/xy_chart/annotations/annotation_utils.ts b/src/chart_types/xy_chart/annotations/annotation_utils.ts index fd7463e263..088f4d164d 100644 --- a/src/chart_types/xy_chart/annotations/annotation_utils.ts +++ b/src/chart_types/xy_chart/annotations/annotation_utils.ts @@ -23,7 +23,7 @@ import { AnnotationRectProps, computeRectAnnotationDimensions, } from './rect_annotation_tooltip'; -import { Rotation, Position } from '../../../utils/commons'; +import { Rotation, Position, Color } from '../../../utils/commons'; export type AnnotationTooltipFormatter = (details?: string) => JSX.Element | null; @@ -54,7 +54,7 @@ export interface AnnotationMarker { icon: JSX.Element; position: { top: number; left: number }; dimension: { width: number; height: number }; - color: string; + color: Color; } export type AnnotationDimensions = AnnotationLineProps[] | AnnotationRectProps[]; diff --git a/src/chart_types/xy_chart/legend/legend.ts b/src/chart_types/xy_chart/legend/legend.ts index defdb8ca8b..72c9a8ec04 100644 --- a/src/chart_types/xy_chart/legend/legend.ts +++ b/src/chart_types/xy_chart/legend/legend.ts @@ -1,11 +1,12 @@ import { getAxesSpecForSpecId, LastValues, getSpecsById } from '../state/utils'; -import { identity } from '../../../utils/commons'; +import { identity, Color } from '../../../utils/commons'; import { SeriesCollectionValue, getSeriesIndex, getSortedDataSeriesColorsValuesMap, getSeriesName, XYChartSeriesIdentifier, + SeriesKey, } from '../utils/series'; import { AxisSpec, BasicSeriesSpec, Postfixes, isAreaSeriesSpec, isBarSeriesSpec } from '../utils/specs'; import { Y0_ACCESSOR_POSTFIX, Y1_ACCESSOR_POSTFIX } from '../tooltip/tooltip'; @@ -17,8 +18,8 @@ interface FormattedLastValues { } export type LegendItem = Postfixes & { - key: string; - color: string; + key: SeriesKey; + color: Color; name: string; seriesIdentifier: XYChartSeriesIdentifier; isSeriesVisible?: boolean; @@ -54,14 +55,14 @@ export function getItemLabel( } export function computeLegend( - seriesCollection: Map, - seriesColors: Map, + seriesCollection: Map, + seriesColors: Map, specs: BasicSeriesSpec[], defaultColor: string, axesSpecs: AxisSpec[], deselectedDataSeries: XYChartSeriesIdentifier[] = [], -): Map { - const legendItems: Map = new Map(); +): Map { + const legendItems: Map = new Map(); const sortedCollection = getSortedDataSeriesColorsValuesMap(seriesCollection); sortedCollection.forEach((series, key) => { diff --git a/src/chart_types/xy_chart/renderer/canvas/axes/index.ts b/src/chart_types/xy_chart/renderer/canvas/axes/index.ts index 6d457607e8..c7c063e41d 100644 --- a/src/chart_types/xy_chart/renderer/canvas/axes/index.ts +++ b/src/chart_types/xy_chart/renderer/canvas/axes/index.ts @@ -24,7 +24,7 @@ export interface AxesProps { axesVisibleTicks: Map; axesSpecs: AxisSpec[]; axesTicksDimensions: Map; - axesPositions: Map; + axesPositions: Map; axisStyle: AxisConfig; debug: boolean; chartDimensions: Dimensions; diff --git a/src/chart_types/xy_chart/rendering/rendering.ts b/src/chart_types/xy_chart/rendering/rendering.ts index f74410db0b..a1d087b2ec 100644 --- a/src/chart_types/xy_chart/rendering/rendering.ts +++ b/src/chart_types/xy_chart/rendering/rendering.ts @@ -22,7 +22,7 @@ import { ClippedRanges, BandedAccessorType, } from '../../../utils/geometry'; -import { mergePartial } from '../../../utils/commons'; +import { mergePartial, Color } from '../../../utils/commons'; import { LegendItem } from '../legend/legend'; export function mutableIndexedGeometryMapUpsert( @@ -91,7 +91,7 @@ function renderPoints( dataSeries: DataSeries, xScale: Scale, yScale: Scale, - color: string, + color: Color, hasY0Accessors: boolean, styleAccessor?: PointStyleAccessor, ): { @@ -170,7 +170,7 @@ export function renderBars( dataSeries: DataSeries, xScale: Scale, yScale: Scale, - color: string, + color: Color, sharedSeriesStyle: BarSeriesStyle, displayValueSettings?: DisplayValueSpec, styleAccessor?: BarStyleAccessor, @@ -310,7 +310,7 @@ export function renderLine( dataSeries: DataSeries, xScale: Scale, yScale: Scale, - color: string, + color: Color, curve: CurveType, hasY0Accessors: boolean, xScaleOffset: number, @@ -399,7 +399,7 @@ export function renderArea( dataSeries: DataSeries, xScale: Scale, yScale: Scale, - color: string, + color: Color, curve: CurveType, hasY0Accessors: boolean, xScaleOffset: number, diff --git a/src/chart_types/xy_chart/state/chart_state.tsx b/src/chart_types/xy_chart/state/chart_state.tsx index 1c0f4497f4..4120f752ee 100644 --- a/src/chart_types/xy_chart/state/chart_state.tsx +++ b/src/chart_types/xy_chart/state/chart_state.tsx @@ -18,6 +18,7 @@ import { getTooltipInfoSelector } from './selectors/get_tooltip_values_highlight import { htmlIdGenerator } from '../../../utils/commons'; import { Tooltip } from '../../../components/tooltip'; import { getTooltipAnchorPositionSelector } from './selectors/get_tooltip_position'; +import { SeriesKey } from '../utils/series'; export class XYAxisChartState implements InternalChartState { chartType = ChartTypes.XYAxis; @@ -34,7 +35,7 @@ export class XYAxisChartState implements InternalChartState { getLegendItems(globalState: GlobalChartState) { return computeLegendSelector(globalState); } - getLegendItemsValues(globalState: GlobalChartState): Map { + getLegendItemsValues(globalState: GlobalChartState): Map { return getLegendTooltipValuesSelector(globalState); } chartRenderer(containerRef: BackwardRef, forwardStageRef: RefObject) { diff --git a/src/chart_types/xy_chart/state/selectors/compute_legend.ts b/src/chart_types/xy_chart/state/selectors/compute_legend.ts index b91f665dff..c9611483d5 100644 --- a/src/chart_types/xy_chart/state/selectors/compute_legend.ts +++ b/src/chart_types/xy_chart/state/selectors/compute_legend.ts @@ -6,6 +6,7 @@ import { getSeriesColorsSelector } from './get_series_color_map'; import { computeLegend, LegendItem } from '../../legend/legend'; import { GlobalChartState } from '../../../../state/chart_state'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; +import { SeriesKey } from '../../utils/series'; const getDeselectedSeriesSelector = (state: GlobalChartState) => state.interactions.deselectedDataSeries; @@ -25,7 +26,7 @@ export const computeLegendSelector = createCachedSelector( seriesColors, axesSpecs, deselectedDataSeries, - ): Map => { + ): Map => { return computeLegend( seriesDomainsAndData.seriesCollection, seriesColors, diff --git a/src/chart_types/xy_chart/state/selectors/get_legend_tooltip_values.ts b/src/chart_types/xy_chart/state/selectors/get_legend_tooltip_values.ts index 35a72e898f..df8a904028 100644 --- a/src/chart_types/xy_chart/state/selectors/get_legend_tooltip_values.ts +++ b/src/chart_types/xy_chart/state/selectors/get_legend_tooltip_values.ts @@ -2,10 +2,11 @@ import createCachedSelector from 're-reselect'; import { getSeriesTooltipValues, TooltipLegendValue } from '../../tooltip/tooltip'; import { getTooltipInfoSelector } from './get_tooltip_values_highlighted_geoms'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; +import { SeriesKey } from '../../utils/series'; export const getLegendTooltipValuesSelector = createCachedSelector( [getTooltipInfoSelector], - ({ values }): Map => { + ({ values }): Map => { return getSeriesTooltipValues(values); }, )(getChartIdSelector); diff --git a/src/chart_types/xy_chart/state/selectors/get_series_color_map.ts b/src/chart_types/xy_chart/state/selectors/get_series_color_map.ts index a38a8ae028..dab4568b7a 100644 --- a/src/chart_types/xy_chart/state/selectors/get_series_color_map.ts +++ b/src/chart_types/xy_chart/state/selectors/get_series_color_map.ts @@ -1,20 +1,27 @@ import createCachedSelector from 're-reselect'; import { computeSeriesDomainsSelector } from './compute_series_domains'; import { getSeriesSpecsSelector } from './get_specs'; -import { getSeriesColors } from '../../utils/series'; +import { getSeriesColors, SeriesKey } from '../../utils/series'; import { getChartThemeSelector } from '../../../../state/selectors/get_chart_theme'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { getCustomSeriesColors } from '../utils'; +import { GlobalChartState } from '../../../../state/chart_state'; +import { Color } from '../../../../utils/commons'; + +function getColorOverrides({ colors }: GlobalChartState) { + return colors; +} export const getSeriesColorsSelector = createCachedSelector( - [getSeriesSpecsSelector, computeSeriesDomainsSelector, getChartThemeSelector], - (seriesSpecs, seriesDomainsAndData, chartTheme): Map => { + [getSeriesSpecsSelector, computeSeriesDomainsSelector, getChartThemeSelector, getColorOverrides], + (seriesSpecs, seriesDomainsAndData, chartTheme, colorOverrides): Map => { const updatedCustomSeriesColors = getCustomSeriesColors(seriesSpecs, seriesDomainsAndData.seriesCollection); const seriesColorMap = getSeriesColors( seriesDomainsAndData.seriesCollection, chartTheme.colors, updatedCustomSeriesColors, + colorOverrides, ); return seriesColorMap; }, diff --git a/src/chart_types/xy_chart/state/selectors/merge_y_custom_domains.ts b/src/chart_types/xy_chart/state/selectors/merge_y_custom_domains.ts index c37c9b47fa..fa76565cd2 100644 --- a/src/chart_types/xy_chart/state/selectors/merge_y_custom_domains.ts +++ b/src/chart_types/xy_chart/state/selectors/merge_y_custom_domains.ts @@ -5,16 +5,20 @@ import { AxisSpec, DomainRange } from '../../utils/specs'; import { Rotation } from '../../../../utils/commons'; import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; +import { GroupId } from '../../../../utils/ids'; export const mergeYCustomDomainsByGroupIdSelector = createCachedSelector( [getAxisSpecsSelector, getSettingsSpecSelector], - (axisSpecs, settingsSpec): Map => { + (axisSpecs, settingsSpec): Map => { return mergeYCustomDomainsByGroupId(axisSpecs, settingsSpec ? settingsSpec.rotation : 0); }, )(getChartIdSelector); -export function mergeYCustomDomainsByGroupId(axesSpecs: AxisSpec[], chartRotation: Rotation): Map { - const domainsByGroupId = new Map(); +export function mergeYCustomDomainsByGroupId( + axesSpecs: AxisSpec[], + chartRotation: Rotation, +): Map { + const domainsByGroupId = new Map(); axesSpecs.forEach((spec: AxisSpec) => { const { id, groupId, domain } = spec; diff --git a/src/chart_types/xy_chart/state/utils.test.ts b/src/chart_types/xy_chart/state/utils.test.ts index 527bb1f05c..fba0ecac1d 100644 --- a/src/chart_types/xy_chart/state/utils.test.ts +++ b/src/chart_types/xy_chart/state/utils.test.ts @@ -36,8 +36,14 @@ import { MockSeriesCollection } from '../../../mocks/series/series_identifiers'; import { SeededDataGenerator } from '../../../mocks/utils'; import { SeriesCollectionValue, getSeriesIndex, getSeriesColors } from '../utils/series'; import { SpecTypes } from '../../../specs/settings'; +import { ColorOverrides } from '../../../state/chart_state'; describe('Chart State utils', () => { + const emptySeriesOverrides: ColorOverrides = { + temporary: {}, + persisted: {}, + }; + it('should compute and format specifications for non stacked chart', () => { const spec1: BasicSeriesSpec = { chartType: ChartTypes.XYAxis, @@ -327,11 +333,10 @@ describe('Chart State utils', () => { // 4 groups generated const data = dg.generateGroupedSeries(50, 4); const targetKey = 'spec{bar1}yAccessor{y}splitAccessors{g-b}'; - const seriesColorOverrides = new Map([[targetKey, 'blue']]); describe('empty series collection and specs', () => { it('it should return an empty map', () => { - const actual = getCustomSeriesColors(MockSeriesSpecs.empty(), MockSeriesCollection.empty(), new Map()); + const actual = getCustomSeriesColors(MockSeriesSpecs.empty(), MockSeriesCollection.empty()); expect(actual.size).toBe(0); }); @@ -343,7 +348,7 @@ describe('Chart State utils', () => { const barSpec2 = MockSeriesSpec.bar({ id: specId2, data }); const barSeriesSpecs = MockSeriesSpecs.fromSpecs([barSpec1, barSpec2]); const barSeriesCollection = MockSeriesCollection.fromSpecs(barSeriesSpecs); - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, new Map()); + const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection); expect(actual.size).toBe(0); }); @@ -354,7 +359,7 @@ describe('Chart State utils', () => { const barSpec2 = MockSeriesSpec.bar({ id: specId2, data }); const barSeriesSpecs = MockSeriesSpecs.fromSpecs([barSpec1, barSpec2]); const barSeriesCollection = MockSeriesCollection.fromSpecs(barSeriesSpecs); - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, new Map()); + const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection); expect([...actual.values()]).toEqualArrayOf(color); }); @@ -367,7 +372,7 @@ describe('Chart State utils', () => { const barSeriesCollection = MockSeriesCollection.fromSpecs(barSeriesSpecs); it('it should return color from color array', () => { - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, new Map()); + const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection); expect(actual.size).toBe(4); barSeriesCollection.forEach(({ seriesIdentifier: { specId, key } }) => { @@ -379,22 +384,6 @@ describe('Chart State utils', () => { } }); }); - - it('it should return color from seriesColorOverrides', () => { - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, seriesColorOverrides); - - expect(actual.size).toBe(4); - barSeriesCollection.forEach(({ seriesIdentifier: { specId, key } }) => { - const color = actual.get(key); - if (key === targetKey) { - expect(color).toBe('blue'); - } else if (specId === specId1) { - expect(customSeriesColors).toContainEqual(color); - } else { - expect(color).toBeUndefined(); - } - }); - }); }); describe('with color function', () => { @@ -411,18 +400,11 @@ describe('Chart State utils', () => { const barSeriesCollection = MockSeriesCollection.fromSpecs(barSeriesSpecs); it('it should return color from color function', () => { - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, new Map()); + const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection); expect(actual.size).toBe(1); expect(actual.get(targetKey)).toBe('aquamarine'); }); - - it('it should return color from seriesColorOverrides', () => { - const actual = getCustomSeriesColors(barSeriesSpecs, barSeriesCollection, seriesColorOverrides); - - expect(actual.size).toBe(1); - expect(actual.get(targetKey)).toBe('blue'); - }); }); }); }); @@ -484,7 +466,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -541,7 +528,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -600,7 +592,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -686,7 +683,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -759,7 +761,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -832,7 +839,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -913,7 +925,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -1006,7 +1023,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -1092,7 +1114,12 @@ describe('Chart State utils', () => { const chartTheme = { ...LIGHT_THEME, colors: chartColors }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, @@ -1157,7 +1184,12 @@ describe('Chart State utils', () => { }; const domainsByGroupId = mergeYCustomDomainsByGroupId(axesSpecs, chartRotation); const seriesDomains = computeSeriesDomains(seriesSpecs, domainsByGroupId, undefined); - const seriesColorMap = getSeriesColors(seriesDomains.seriesCollection, chartColors, new Map()); + const seriesColorMap = getSeriesColors( + seriesDomains.seriesCollection, + chartColors, + new Map(), + emptySeriesOverrides, + ); const geometries = computeSeriesGeometries( seriesSpecs, seriesDomains.xDomain, diff --git a/src/chart_types/xy_chart/state/utils.ts b/src/chart_types/xy_chart/state/utils.ts index ad127ec926..13d83a74a7 100644 --- a/src/chart_types/xy_chart/state/utils.ts +++ b/src/chart_types/xy_chart/state/utils.ts @@ -14,6 +14,7 @@ import { getSeriesKey, RawDataSeries, XYChartSeriesIdentifier, + SeriesKey, } from '../utils/series'; import { AreaSeriesSpec, @@ -32,7 +33,7 @@ import { SeriesTypes, } from '../utils/specs'; import { ColorConfig, Theme } from '../../../utils/themes/theme'; -import { identity, mergePartial, Rotation } from '../../../utils/commons'; +import { identity, mergePartial, Rotation, Color } from '../../../utils/commons'; import { Dimensions } from '../../../utils/dimensions'; import { Domain } from '../../../utils/domain'; import { GroupId, SpecId } from '../../../utils/ids'; @@ -91,7 +92,7 @@ export interface SeriesDomainsAndData { stacked: FormattedDataSeries[]; nonStacked: FormattedDataSeries[]; }; - seriesCollection: Map; + seriesCollection: Map; } /** @@ -122,24 +123,19 @@ export function updateDeselectedDataSeries( */ export function getCustomSeriesColors( seriesSpecs: BasicSeriesSpec[], - seriesCollection: Map, - seriesColorOverrides: Map = new Map(), -): Map { - const updatedCustomSeriesColors = new Map(); + seriesCollection: Map, +): Map { + const updatedCustomSeriesColors = new Map(); const counters = new Map(); seriesCollection.forEach(({ seriesIdentifier }, seriesKey) => { const spec = getSpecsById(seriesSpecs, seriesIdentifier.specId); - if (!spec || !(spec.color || seriesColorOverrides.size > 0)) { + if (!spec || !spec.color) { return; } - let color: string | undefined | null; - - if (seriesColorOverrides.has(seriesKey)) { - color = seriesColorOverrides.get(seriesKey); - } + let color: Color | undefined | null; if (!color && spec.color) { if (typeof spec.color === 'string') { @@ -166,8 +162,8 @@ export interface LastValues { function getLastValues(formattedDataSeries: { stacked: FormattedDataSeries[]; nonStacked: FormattedDataSeries[]; -}): Map { - const lastValues = new Map(); +}): Map { + const lastValues = new Map(); // we need to get the latest formattedDataSeries.stacked.forEach((ds) => { @@ -238,7 +234,7 @@ export function computeSeriesDomains( // we need to get the last values from the formatted dataseries // because we change the format if we are on percentage mode const lastValues = getLastValues(formattedDataSeries); - const updatedSeriesCollection = new Map(); + const updatedSeriesCollection = new Map(); seriesCollection.forEach((value, key) => { const lastValue = lastValues.get(key); const updatedColorSet: SeriesCollectionValue = { @@ -264,7 +260,7 @@ export function computeSeriesGeometries( stacked: FormattedDataSeries[]; nonStacked: FormattedDataSeries[]; }, - seriesColorMap: Map, + seriesColorMap: Map, chartTheme: Theme, chartDims: Dimensions, chartRotation: Rotation, @@ -450,7 +446,7 @@ function renderGeometries( xScale: Scale, yScale: Scale, seriesSpecs: BasicSeriesSpec[], - seriesColorsMap: Map, + seriesColorsMap: Map, defaultColor: string, axesSpecs: AxisSpec[], chartTheme: Theme, diff --git a/src/chart_types/xy_chart/tooltip/tooltip.ts b/src/chart_types/xy_chart/tooltip/tooltip.ts index 76ee34ea9c..bd80731086 100644 --- a/src/chart_types/xy_chart/tooltip/tooltip.ts +++ b/src/chart_types/xy_chart/tooltip/tooltip.ts @@ -8,7 +8,7 @@ import { } from '../utils/specs'; import { IndexedGeometry, BandedAccessorType } from '../../../utils/geometry'; import { getAccessorFormatLabel } from '../../../utils/accessor'; -import { getSeriesName } from '../utils/series'; +import { getSeriesName, SeriesKey } from '../utils/series'; import { TooltipValue } from '../../../specs'; export interface TooltipLegendValue { @@ -22,9 +22,9 @@ export const Y1_ACCESSOR_POSTFIX = ' - upper'; export function getSeriesTooltipValues( tooltipValues: TooltipValue[], defaultValue?: string, -): Map { +): Map { // map from seriesKey to TooltipLegendValue - const seriesTooltipValues = new Map(); + const seriesTooltipValues = new Map(); tooltipValues.forEach(({ value, seriesIdentifier, valueAccessor }) => { const seriesValue = defaultValue ? defaultValue : value; diff --git a/src/chart_types/xy_chart/utils/series.test.ts b/src/chart_types/xy_chart/utils/series.test.ts index 4898d28150..9310adb5aa 100644 --- a/src/chart_types/xy_chart/utils/series.test.ts +++ b/src/chart_types/xy_chart/utils/series.test.ts @@ -485,32 +485,16 @@ describe('Series', () => { ); expect(stackedDataSeries.stacked).toMatchSnapshot(); }); - test('should get series color map', () => { - const spec1: BasicSeriesSpec = { - specType: SpecTypes.Series, - chartType: ChartTypes.XYAxis, - id: 'spec1', - groupId: 'group', - seriesType: SeriesTypes.Line, - yScaleType: ScaleType.Log, - xScaleType: ScaleType.Linear, - xAccessor: 'x', - yAccessors: ['y'], - yScaleToDataExtent: false, - data: TestDataset.BARCHART_1Y0G, - hideInLegend: false, - }; - - const specs = new Map(); - specs.set(spec1.id, spec1); - const dataSeriesValuesA: SeriesCollectionValue = { + describe('#getSeriesColors', () => { + const seriesKey = 'mock_series_key'; + const mockSeries: SeriesCollectionValue = { seriesIdentifier: { specId: 'spec1', yAccessor: 'y1', splitAccessors: new Map(), seriesKeys: ['a', 'b', 'c'], - key: '', + key: seriesKey, }, }; @@ -520,22 +504,53 @@ describe('Series', () => { }; const seriesColors = new Map(); - seriesColors.set('spec1', dataSeriesValuesA); + seriesColors.set(seriesKey, mockSeries); const emptyCustomColors = new Map(); + const persistedColor = 'persisted_color'; + const customColor = 'custom_color'; + const customColors: Map = new Map(); + customColors.set(seriesKey, customColor); + const emptyColorOverrides = { + persisted: {}, + temporary: {}, + }; + const persistedOverrides = { + persisted: { [seriesKey]: persistedColor }, + temporary: {}, + }; - const defaultColorMap = getSeriesColors(seriesColors, chartColors, emptyCustomColors); - const expectedDefaultColorMap = new Map(); - expectedDefaultColorMap.set('spec1', 'elastic_charts_c1'); - expect(defaultColorMap).toEqual(expectedDefaultColorMap); + it('should return deafult color', () => { + const result = getSeriesColors(seriesColors, chartColors, emptyCustomColors, emptyColorOverrides); + const expected = new Map(); + expected.set(seriesKey, 'elastic_charts_c1'); + expect(result).toEqual(expected); + }); - const customColors: Map = new Map(); - customColors.set('spec1', 'custom_color'); + it('should return persisted color', () => { + const result = getSeriesColors(seriesColors, chartColors, emptyCustomColors, persistedOverrides); + const expected = new Map(); + expected.set(seriesKey, persistedColor); + expect(result).toEqual(expected); + }); + + it('should return custom color', () => { + const result = getSeriesColors(seriesColors, chartColors, customColors, persistedOverrides); + const expected = new Map(); + expected.set(seriesKey, customColor); + expect(result).toEqual(expected); + }); - const customizedColorMap = getSeriesColors(seriesColors, chartColors, customColors); - const expectedCustomizedColorMap = new Map(); - expectedCustomizedColorMap.set('spec1', 'custom_color'); - expect(customizedColorMap).toEqual(expectedCustomizedColorMap); + it('should return temporary color', () => { + const temporaryColor = 'persisted-color'; + const result = getSeriesColors(seriesColors, chartColors, customColors, { + ...persistedOverrides, + temporary: { [seriesKey]: temporaryColor }, + }); + const expected = new Map(); + expected.set(seriesKey, temporaryColor); + expect(result).toEqual(expected); + }); }); test('should only include deselectedDataSeries when splitting series if deselectedDataSeries is defined', () => { const specId = 'splitSpec'; diff --git a/src/chart_types/xy_chart/utils/series.ts b/src/chart_types/xy_chart/utils/series.ts index a7b6304cd2..3cb0c84aa1 100644 --- a/src/chart_types/xy_chart/utils/series.ts +++ b/src/chart_types/xy_chart/utils/series.ts @@ -7,7 +7,8 @@ import { BasicSeriesSpec, SeriesTypes, SeriesSpecs, SeriesNameConfigOptions } fr import { formatStackedDataSeriesValues } from './stacked_series_utils'; import { ScaleType } from '../../../scales'; import { LastValues } from '../state/utils'; -import { Datum } from '../../../utils/commons'; +import { Datum, Color } from '../../../utils/commons'; +import { ColorOverrides } from '../../../state/chart_state'; export const SERIES_DELIMITER = ' - '; @@ -47,9 +48,12 @@ export interface DataSeriesDatum { /** the list of filled values because missing or nulls */ filled?: FilledValues; } + +export type SeriesKey = string; + export type SeriesIdentifier = { specId: SpecId; - key: string; + key: SeriesKey; }; export interface XYChartSeriesIdentifier extends SeriesIdentifier { @@ -113,7 +117,7 @@ export function splitSeries({ xValues: Set; } { const isMultipleY = yAccessors && yAccessors.length > 1; - const series = new Map(); + const series = new Map(); const colorsValues = new Set(); const xValues = new Set(); @@ -166,7 +170,7 @@ export function getSeriesKey({ * along with the series key */ function updateSeriesMap( - seriesMap: Map, + seriesMap: Map, splitAccessors: Map, accessor: any, datum: RawDataSeriesDatum, @@ -339,11 +343,11 @@ export function getSplittedSeries( deselectedDataSeries: XYChartSeriesIdentifier[] = [], ): { splittedSeries: Map; - seriesCollection: Map; + seriesCollection: Map; xValues: Set; } { const splittedSeries = new Map(); - const seriesCollection = new Map(); + const seriesCollection = new Map(); const xValues: Set = new Set(); let isOrdinalScale = false; for (const spec of seriesSpecs) { @@ -464,8 +468,8 @@ function getSortIndex({ specSortIndex }: SeriesCollectionValue, total: number): } export function getSortedDataSeriesColorsValuesMap( - seriesCollection: Map, -): Map { + seriesCollection: Map, +): Map { const seriesColorsArray = [...seriesCollection]; seriesColorsArray.sort(([, specA], [, specB]) => { return getSortIndex(specA, seriesCollection.size) - getSortIndex(specB, seriesCollection.size); @@ -474,17 +478,55 @@ export function getSortedDataSeriesColorsValuesMap( return new Map([...seriesColorsArray]); } +/** + * Helper function to get highest override color. + * + * from highest to lowest: `temporary`, `seriesSpec.color` then `persisted` + * + * @param key + * @param customColors + * @param overrides + */ +function getHighestOverride( + key: string, + customColors: Map, + overrides: ColorOverrides, +): Color | undefined { + let color: Color | undefined = overrides.temporary[key]; + + if (color) { + return color; + } + + color = customColors.get(key); + + if (color) { + return color; + } + + return overrides.persisted[key]; +} + +/** + * Returns color for a series given all color hierarchies + * + * @param seriesCollection + * @param chartColors + * @param customColors + * @param overrides + */ export function getSeriesColors( - seriesCollection: Map, + seriesCollection: Map, chartColors: ColorConfig, - customColors: Map, -): Map { - const seriesColorMap = new Map(); + customColors: Map, + overrides: ColorOverrides, +): Map { + const seriesColorMap = new Map(); let counter = 0; seriesCollection.forEach((_, seriesKey) => { - const customSeriesColor: string | undefined = customColors.get(seriesKey); - const color = customSeriesColor || chartColors.vizColors[counter % chartColors.vizColors.length]; + const colorOverride = getHighestOverride(seriesKey, customColors, overrides); + const color = colorOverride || chartColors.vizColors[counter % chartColors.vizColors.length]; seriesColorMap.set(seriesKey, color); counter++; diff --git a/src/components/chart.tsx b/src/components/chart.tsx index d5a86145ef..5c702bc249 100644 --- a/src/components/chart.tsx +++ b/src/components/chart.tsx @@ -1,7 +1,7 @@ import React, { CSSProperties, createRef } from 'react'; import classNames from 'classnames'; import { Provider } from 'react-redux'; -import { createStore, Store } from 'redux'; +import { createStore, Store, Unsubscribe } from 'redux'; import uuid from 'uuid'; import { SpecsParser } from '../specs/specs_parser'; import { ChartResizer } from './chart_resizer'; @@ -51,6 +51,7 @@ export class Chart extends React.Component { static defaultProps: ChartProps = { renderer: 'canvas', }; + private unsubscribeToStore: Unsubscribe; private chartStore: Store; private chartContainerRef: React.RefObject; private chartStageRef: React.RefObject; @@ -77,11 +78,12 @@ export class Chart extends React.Component { const onElementOutCaller = createOnElementOutCaller(); const onBrushEndCaller = createOnBrushEndCaller(); const onPointerMoveCaller = createOnPointerMoveCaller(); - this.chartStore.subscribe(() => { + this.unsubscribeToStore = this.chartStore.subscribe(() => { const state = this.chartStore.getState(); if (!isInitialized(state)) { return; } + const settings = getSettingsSpecSelector(state); if (this.state.legendPosition !== settings.legendPosition) { this.setState({ @@ -100,6 +102,10 @@ export class Chart extends React.Component { }); } + componentWillUnmount() { + this.unsubscribeToStore(); + } + dispatchExternalPointerEvent(event: PointerEvent) { this.chartStore.dispatch(onExternalPointerEvent(event)); } diff --git a/src/components/legend/__snapshots__/legend.test.tsx.snap b/src/components/legend/__snapshots__/legend.test.tsx.snap new file mode 100644 index 0000000000..b4f88a8934 --- /dev/null +++ b/src/components/legend/__snapshots__/legend.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Legend #legendColorPicker should match snapshot after onChange is called 1`] = `"
splita
splitb
splitc
splitd
"`; + +exports[`Legend #legendColorPicker should match snapshot after onClose is called 1`] = `"
splita
splitb
splitc
splitd
"`; + +exports[`Legend #legendColorPicker should render colorPicker when color is clicked 1`] = `"
Custom Color Picker
"`; + +exports[`Legend #legendColorPicker should render colorPicker when color is clicked 2`] = `"
splita
Custom Color Picker
splitb
splitc
splitd
"`; diff --git a/src/components/legend/_legend_item.scss b/src/components/legend/_legend_item.scss index 70a62d079c..36e2cbf702 100644 --- a/src/components/legend/_legend_item.scss +++ b/src/components/legend/_legend_item.scss @@ -9,9 +9,9 @@ $legendItemVerticalPadding: $echLegendRowGap / 2; align-items: center; width: 100%; - &:hover { - .echLegendItem__label { - text-decoration: underline; + &:not(&--hidden) { + .echLegendItem__color--changable { + cursor: pointer; } } @@ -34,6 +34,7 @@ $legendItemVerticalPadding: $echLegendRowGap / 2; &:hover { cursor: pointer; + text-decoration: underline; } } diff --git a/src/components/legend/legend.test.tsx b/src/components/legend/legend.test.tsx index f64b6a1781..252cae2fb5 100644 --- a/src/components/legend/legend.test.tsx +++ b/src/components/legend/legend.test.tsx @@ -1,11 +1,13 @@ -import React from 'react'; -import { mount } from 'enzyme'; +import React, { Component } from 'react'; +import { mount, ReactWrapper } from 'enzyme'; import { Chart } from '../chart'; -import { Settings, BarSeries } from '../../specs'; +import { Settings, BarSeries, LegendColorPicker } from '../../specs'; import { ScaleType } from '../../scales'; -import { DataGenerator } from '../../utils/data_generators/data_generator'; import { Legend } from './legend'; import { LegendListItem } from './legend_item'; +import { SeededDataGenerator } from '../../mocks/utils'; + +const dg = new SeededDataGenerator(); describe('Legend', () => { it('shall render the all the series names', () => { @@ -73,7 +75,6 @@ describe('Legend', () => { it('shall call the over and out listeners for every list item', () => { const onLegendItemOver = jest.fn(); const onLegendItemOut = jest.fn(); - const dg = new DataGenerator(); const numberOfSeries = 4; const data = dg.generateGroupedSeries(10, numberOfSeries, 'split'); const wrapper = mount( @@ -103,7 +104,6 @@ describe('Legend', () => { }); it('shall call click listener for every list item', () => { const onLegendItemClick = jest.fn(); - const dg = new DataGenerator(); const numberOfSeries = 4; const data = dg.generateGroupedSeries(10, numberOfSeries, 'split'); const wrapper = mount( @@ -131,4 +131,160 @@ describe('Legend', () => { expect(onLegendItemClick).toBeCalledTimes(i + 1); }); }); + + describe('#legendColorPicker', () => { + class LegendColorPickerMock extends Component< + { onLegendItemClick: () => void; customColor: string }, + { colors: string[] } + > { + state = { + colors: ['red'], + }; + + data = dg.generateGroupedSeries(10, 4, 'split'); + + legendColorPickerFn: LegendColorPicker = ({ onClose }) => { + return ( +
+ Custom Color Picker + + +
+ ); + }; + + render() { + return ( + + + + + ); + } + } + + let wrapper: ReactWrapper; + const customColor = '#0c7b93'; + const onLegendItemClick = jest.fn(); + + beforeEach(() => { + wrapper = mount(); + }); + + const clickFirstColor = () => { + const legendWrapper = wrapper.find(Legend); + expect(legendWrapper.exists).toBeTruthy(); + const legendItems = legendWrapper.find(LegendListItem); + expect(legendItems.exists).toBeTruthy(); + expect(legendItems).toHaveLength(4); + legendItems + .first() + .find('.echLegendItem__color') + .simulate('click'); + }; + + it('should render colorPicker when color is clicked', () => { + clickFirstColor(); + expect(wrapper.find('#colorPicker').html()).toMatchSnapshot(); + expect( + wrapper + .find(LegendListItem) + .map((e) => e.html()) + .join(''), + ).toMatchSnapshot(); + }); + + it('should match snapshot after onChange is called', () => { + clickFirstColor(); + wrapper + .find('#change') + .simulate('click') + .first(); + + expect( + wrapper + .find(LegendListItem) + .map((e) => e.html()) + .join(''), + ).toMatchSnapshot(); + }); + + it('should set isOpen to false after onChange is called', () => { + clickFirstColor(); + wrapper + .find('#change') + .simulate('click') + .first(); + expect(wrapper.find('#colorPicker').exists()).toBe(false); + }); + + it('should set color after onChange is called', () => { + clickFirstColor(); + wrapper + .find('#change') + .simulate('click') + .first(); + const dot = wrapper.find('.echLegendItem__color svg'); + expect(dot.exists(`[color="${customColor}"]`)).toBe(true); + }); + + it('should match snapshot after onClose is called', () => { + clickFirstColor(); + wrapper + .find('#close') + .simulate('click') + .first(); + expect( + wrapper + .find(LegendListItem) + .map((e) => e.html()) + .join(''), + ).toMatchSnapshot(); + }); + + it('should set isOpen to false after onClose is called', () => { + clickFirstColor(); + wrapper + .find('#close') + .simulate('click') + .first(); + expect(wrapper.find('#colorPicker').exists()).toBe(false); + }); + + it('should call click listener for every list item', () => { + const legendWrapper = wrapper.find(Legend); + expect(legendWrapper.exists).toBeTruthy(); + const legendItems = legendWrapper.find(LegendListItem); + expect(legendItems.exists).toBeTruthy(); + expect(legendItems).toHaveLength(4); + legendItems.forEach((legendItem, i) => { + // toggle click is only enabled on the title + legendItem.find('.echLegendItem__label').simulate('click'); + expect(onLegendItemClick).toBeCalledTimes(i + 1); + }); + }); + }); }); diff --git a/src/components/legend/legend.tsx b/src/components/legend/legend.tsx index 91f6d14e2f..6037f7bc52 100644 --- a/src/components/legend/legend.tsx +++ b/src/components/legend/legend.tsx @@ -21,11 +21,13 @@ import { onLegendItemOutAction, onLegendItemOverAction, } from '../../state/actions/legend'; +import { clearTemporaryColors, setTemporaryColor, setPersistedColor } from '../../state/actions/colors'; import { SettingsSpec } from '../../specs'; import { BandedAccessorType } from '../../utils/geometry'; +import { SeriesKey } from '../../chart_types/xy_chart/utils/series'; interface LegendStateProps { - legendItems: Map; + legendItems: Map; legendPosition: Position; legendItemTooltipValues: Map; showLegend: boolean; @@ -40,6 +42,9 @@ interface LegendDispatchProps { onLegendItemOutAction: typeof onLegendItemOutAction; onLegendItemOverAction: typeof onLegendItemOverAction; onToggleDeselectSeriesAction: typeof onToggleDeselectSeriesAction; + clearTemporaryColors: typeof clearTemporaryColors; + setTemporaryColor: typeof setTemporaryColor; + setPersistedColor: typeof setPersistedColor; } type LegendProps = LegendStateProps & LegendDispatchProps; @@ -119,8 +124,8 @@ class LegendComponent extends React.Component { }; private getLegendValues( - tooltipValues: Map | undefined, - key: string, + tooltipValues: Map | undefined, + key: SeriesKey, banded: boolean = false, ): any[] { const values = tooltipValues && tooltipValues.get(key); @@ -138,21 +143,25 @@ class LegendComponent extends React.Component { } const { key, displayValue, banded } = item; const { legendItemTooltipValues, settings } = this.props; - const { showLegendExtra, legendPosition } = settings; + const { showLegendExtra, legendPosition, legendColorPicker } = settings; const legendValues = this.getLegendValues(legendItemTooltipValues, key, banded); return legendValues.map((value, index) => { const yAccessor: BandedAccessorType = index === 0 ? BandedAccessorType.Y1 : BandedAccessorType.Y0; return ( onToggleDeselectSeriesAction, onLegendItemOutAction, onLegendItemOverAction, + clearTemporaryColors, + setTemporaryColor, + setPersistedColor, }, dispatch, ); diff --git a/src/components/legend/legend_item.tsx b/src/components/legend/legend_item.tsx index 4a479c404e..7d342b3e86 100644 --- a/src/components/legend/legend_item.tsx +++ b/src/components/legend/legend_item.tsx @@ -1,12 +1,13 @@ import classNames from 'classnames'; -import React from 'react'; +import React, { Component, createRef } from 'react'; import { deepEqual } from '../../utils/fast_deep_equal'; import { Icon } from '../icons/icon'; -import { LegendItemListener, BasicListener } from '../../specs/settings'; +import { LegendItemListener, BasicListener, LegendColorPicker } from '../../specs/settings'; import { LegendItem } from '../../chart_types/xy_chart/legend/legend'; import { onLegendItemOutAction, onLegendItemOverAction } from '../../state/actions/legend'; -import { Position } from '../../utils/commons'; +import { Position, Color } from '../../utils/commons'; import { XYChartSeriesIdentifier } from '../../chart_types/xy_chart/utils/series'; +import { clearTemporaryColors, setTemporaryColor, setPersistedColor } from '../../state/actions/colors'; interface LegendItemProps { legendItem: LegendItem; @@ -14,11 +15,15 @@ interface LegendItemProps { label?: string; legendPosition: Position; showExtra: boolean; + legendColorPicker?: LegendColorPicker; onLegendItemClickListener?: LegendItemListener; onLegendItemOutListener?: BasicListener; onLegendItemOverListener?: LegendItemListener; legendItemOutAction: typeof onLegendItemOutAction; legendItemOverAction: typeof onLegendItemOverAction; + clearTemporaryColors: typeof clearTemporaryColors; + setTemporaryColor: typeof setTemporaryColor; + setPersistedColor: typeof setPersistedColor; toggleDeselectSeriesAction: (legendItemId: XYChartSeriesIdentifier) => void; } @@ -62,63 +67,127 @@ function renderLabel( ); } -/** - * Create a div for the color/eye icon - * @param color - * @param isSeriesVisible - */ -function renderColor(color?: string, isSeriesVisible = true) { - if (!color) { - return null; +interface LegendItemState { + isOpen: boolean; +} + +export class LegendListItem extends Component { + static displayName = 'LegendItem'; + ref = createRef(); + + state: LegendItemState = { + isOpen: false, + }; + + shouldComponentUpdate(nextProps: LegendItemProps, nextState: LegendItemState) { + return !deepEqual(this.props, nextProps) || !deepEqual(this.state, nextState); } - // TODO add color picker - if (isSeriesVisible) { + + handleColorClick = (changable: boolean) => + changable + ? (event: React.MouseEvent) => { + event.stopPropagation(); + this.toggleIsOpen(); + } + : undefined; + + /** + * Create a div for the color/eye icon + * @param color + * @param isSeriesVisible + */ + renderColor = (color?: string, isSeriesVisible = true) => { + if (!color) { + return null; + } + + if (!isSeriesVisible) { + return ( +
+ {/* changing the default viewBox for the eyeClosed icon to keep the same dimensions */} + +
+ ); + } + + const changable = Boolean(this.props.legendColorPicker); + const colorClasses = classNames('echLegendItem__color', { + 'echLegendItem__color--changable': changable, + }); + return ( -
+
); - } - // changing the default viewBox for the eyeClosed icon to keep the same dimensions - return ( -
- -
- ); -} + }; -export class LegendListItem extends React.Component { - static displayName = 'LegendItem'; + renderColorPicker() { + const { + legendColorPicker: ColorPicker, + legendItem, + clearTemporaryColors, + setTemporaryColor, + setPersistedColor, + } = this.props; + const { seriesIdentifier, color } = legendItem; - shouldComponentUpdate(nextProps: LegendItemProps) { - return !deepEqual(this.props, nextProps); + const handleClose = () => { + setPersistedColor(seriesIdentifier.key, color); + clearTemporaryColors(); + this.toggleIsOpen(); + }; + + if (ColorPicker && this.state.isOpen && this.ref.current) { + return ( + setTemporaryColor(seriesIdentifier.key, color)} + seriesIdentifier={seriesIdentifier} + /> + ); + } } render() { const { extra, legendItem, legendPosition, label, showExtra, onLegendItemClickListener } = this.props; const { color, isSeriesVisible, seriesIdentifier, isLegendItemVisible } = legendItem; - const onLabelClick = this.onVisibilityClick(seriesIdentifier); const hasLabelClickListener = Boolean(onLegendItemClickListener); const itemClassNames = classNames('echLegendItem', `echLegendItem--${legendPosition}`, { - 'echLegendItem-isHidden': !isSeriesVisible, + 'echLegendItem--hidden': !isSeriesVisible, 'echLegendItem__extra--hidden': !isLegendItemVisible, }); return ( -
- {renderColor(color, isSeriesVisible)} - {renderLabel(onLabelClick, hasLabelClickListener, label)} - {showExtra && renderExtra(extra, isSeriesVisible)} -
+ <> +
+ {this.renderColor(color, isSeriesVisible)} + {renderLabel(onLabelClick, hasLabelClickListener, label)} + {showExtra && renderExtra(extra, isSeriesVisible)} +
+ {this.renderColorPicker()} + ); } + toggleIsOpen = () => { + this.setState(({ isOpen }) => ({ isOpen: !isOpen })); + }; + onLegendItemMouseOver = () => { const { onLegendItemOverListener, legendItemOverAction, legendItem } = this.props; // call the settings listener directly if available diff --git a/src/specs/settings.tsx b/src/specs/settings.tsx index d316532ef5..68120c9b15 100644 --- a/src/specs/settings.tsx +++ b/src/specs/settings.tsx @@ -1,4 +1,6 @@ +import { ComponentType } from 'react'; import { $Values } from 'utility-types'; + import { DomainRange } from '../chart_types/xy_chart/utils/specs'; import { PartialTheme, Theme } from '../utils/themes/theme'; import { Domain } from '../utils/domain'; @@ -8,9 +10,9 @@ import { LIGHT_THEME } from '../utils/themes/light_theme'; import { ChartTypes } from '../chart_types'; import { GeometryValue } from '../utils/geometry'; import { XYChartSeriesIdentifier, SeriesIdentifier } from '../chart_types/xy_chart/utils/series'; -import { Position, Rendering, Rotation } from '../utils/commons'; -import { ScaleContinuousType, ScaleOrdinalType } from '../scales'; import { Accessor } from '../utils/accessor'; +import { Position, Rendering, Rotation, Color } from '../utils/commons'; +import { ScaleContinuousType, ScaleOrdinalType } from '../scales'; export type ElementClickListener = (elements: Array<[GeometryValue, XYChartSeriesIdentifier]>) => void; export type ElementOverListener = (elements: Array<[GeometryValue, XYChartSeriesIdentifier]>) => void; @@ -83,7 +85,7 @@ export interface TooltipValue { /** * The color of the graphic mark (by default the color of the series) */ - color: string; + color: Color; /** * True if the mouse is over the graphic mark connected to the tooltip */ @@ -111,6 +113,30 @@ export interface TooltipProps { unit?: string; } +export interface LegendColorPickerProps { + /** + * Anchor used to position picker + */ + anchor: HTMLDivElement; + /** + * Current color of the given series + */ + color: Color; + /** + * Callback to close color picker and set persistent color + */ + onClose: () => void; + /** + * Callback to update temporary color state + */ + onChange: (color: Color) => void; + /** + * Series id for the active series + */ + seriesIdentifier: XYChartSeriesIdentifier; +} +export type LegendColorPicker = ComponentType; + export interface SettingsSpec extends Spec { /** * Partial theme to be merged with base @@ -161,6 +187,7 @@ export interface SettingsSpec extends Spec { onRenderChange?: RenderChangeListener; xDomain?: Domain | DomainRange; resizeDebounce?: number; + legendColorPicker?: LegendColorPicker; } export type DefaultSettingsProps = diff --git a/src/state/actions/colors.ts b/src/state/actions/colors.ts new file mode 100644 index 0000000000..61e571a240 --- /dev/null +++ b/src/state/actions/colors.ts @@ -0,0 +1,36 @@ +import { SeriesKey } from '../../chart_types/xy_chart/utils/series'; +import { Color } from '../../utils/commons'; + +export const CLEAR_TEMPORARY_COLORS = 'CLEAR_TEMPORARY_COLORS'; +export const SET_TEMPORARY_COLOR = 'SET_TEMPORARY_COLOR'; +export const SET_PERSISTED_COLOR = 'SET_PERSISTED_COLOR'; + +interface ClearTemporaryColors { + type: typeof CLEAR_TEMPORARY_COLORS; +} + +interface SetTemporaryColor { + type: typeof SET_TEMPORARY_COLOR; + key: SeriesKey; + color: Color; +} + +interface SetPersistedColor { + type: typeof SET_PERSISTED_COLOR; + key: SeriesKey; + color: Color; +} + +export function clearTemporaryColors(): ClearTemporaryColors { + return { type: CLEAR_TEMPORARY_COLORS }; +} + +export function setTemporaryColor(key: SeriesKey, color: Color): SetTemporaryColor { + return { type: SET_TEMPORARY_COLOR, key, color }; +} + +export function setPersistedColor(key: SeriesKey, color: Color): SetPersistedColor { + return { type: SET_PERSISTED_COLOR, key, color }; +} + +export type ColorsActions = ClearTemporaryColors | SetTemporaryColor | SetPersistedColor; diff --git a/src/state/actions/index.ts b/src/state/actions/index.ts index 7044bba8b6..64f2c49148 100644 --- a/src/state/actions/index.ts +++ b/src/state/actions/index.ts @@ -4,6 +4,7 @@ import { ChartSettingsActions } from './chart_settings'; import { LegendActions } from './legend'; import { EventsActions } from './events'; import { MouseActions } from './mouse'; +import { ColorsActions } from './colors'; export type StateActions = | SpecActions @@ -11,4 +12,5 @@ export type StateActions = | ChartSettingsActions | LegendActions | EventsActions - | MouseActions; + | MouseActions + | ColorsActions; diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index 1cb0c4d29e..c392883e36 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -1,8 +1,9 @@ import { SPEC_PARSED, SPEC_UNMOUNTED, UPSERT_SPEC, REMOVE_SPEC, SPEC_PARSING } from './actions/specs'; +import { SET_PERSISTED_COLOR, SET_TEMPORARY_COLOR, CLEAR_TEMPORARY_COLORS } from './actions/colors'; import { interactionsReducer } from './reducers/interactions'; import { ChartTypes } from '../chart_types'; import { XYAxisChartState } from '../chart_types/xy_chart/state/chart_state'; -import { XYChartSeriesIdentifier } from '../chart_types/xy_chart/utils/series'; +import { XYChartSeriesIdentifier, SeriesKey } from '../chart_types/xy_chart/utils/series'; import { Spec, PointerEvent } from '../specs'; import { DEFAULT_SETTINGS_SPEC } from '../specs/settings'; import { Dimensions } from '../utils/dimensions'; @@ -17,6 +18,7 @@ import { RefObject } from 'react'; import { PartitionState } from '../chart_types/partition_chart/state/chart_state'; import { TooltipInfo } from '../components/tooltip/types'; import { TooltipAnchorPosition } from '../components/tooltip/utils'; +import { Color } from '../utils/commons'; export type BackwardRef = () => React.RefObject; @@ -54,12 +56,12 @@ export interface InternalChartState { * return the list of legend items * @param globalState */ - getLegendItems(globalState: GlobalChartState): Map; + getLegendItems(globalState: GlobalChartState): Map; /** * return the list of values for each legend item * @param globalState */ - getLegendItemsValues(globalState: GlobalChartState): Map; + getLegendItemsValues(globalState: GlobalChartState): Map; /** * return the CSS pointer cursor depending on the internal chart state * @param globalState @@ -116,27 +118,56 @@ export interface ExternalEventsState { pointer: PointerEvent | null; } +export interface ColorOverrides { + temporary: Record; + persisted: Record; +} + export interface GlobalChartState { - // an unique ID for each chart used by re-reselect to memoize selector per chart + /** + * a unique ID for each chart used by re-reselect to memoize selector per chart + */ chartId: string; - // true when all all the specs are parsed ad stored into the specs object + /** + * true when all all the specs are parsed ad stored into the specs object + */ specsInitialized: boolean; - // true if the chart is rendered on dom + /** + * true if the chart is rendered on dom + */ chartRendered: boolean; - // incremental count of the chart rendering + /** + * incremental count of the chart rendering + */ chartRenderedCount: number; - // the map of parsed specs + /** + * the map of parsed specs + */ specs: SpecList; - // the chart type depending on the used specs + /** + * the chart type depending on the used specs + */ chartType: ChartTypes | null; - // a chart-type-dependant class that is used to render and share chart-type dependant functions + /** + * a chart-type-dependant class that is used to render and share chart-type dependant functions + */ internalChartState: InternalChartState | null; - // the dimensions of the parent container, including the legend + /** + * the dimensions of the parent container, including the legend + */ parentDimensions: Dimensions; - // the state of the interactions + /** + * the state of the interactions + */ interactions: InteractionsState; - // external event state + /** + * external event state + */ externalEvents: ExternalEventsState; + /** + * Color map used to persist color picker changes + */ + colors: ColorOverrides; } export const getInitialState = (chartId: string): GlobalChartState => ({ @@ -147,6 +178,10 @@ export const getInitialState = (chartId: string): GlobalChartState => ({ specs: { [DEFAULT_SETTINGS_SPEC.id]: DEFAULT_SETTINGS_SPEC, }, + colors: { + temporary: {}, + persisted: {}, + }, chartType: null, internalChartState: null, interactions: { @@ -267,6 +302,36 @@ export const chartStoreReducer = (chartId: string) => { }, }, }; + case CLEAR_TEMPORARY_COLORS: + return { + ...state, + colors: { + ...state.colors, + temporary: {}, + }, + }; + case SET_TEMPORARY_COLOR: + return { + ...state, + colors: { + ...state.colors, + temporary: { + ...state.colors.temporary, + [action.key]: action.color, + }, + }, + }; + case SET_PERSISTED_COLOR: + return { + ...state, + colors: { + ...state.colors, + persisted: { + ...state.colors.persisted, + [action.key]: action.color, + }, + }, + }; default: return { ...state, diff --git a/src/state/selectors/get_legend_items.ts b/src/state/selectors/get_legend_items.ts index d3e1888e4c..8b9064cb80 100644 --- a/src/state/selectors/get_legend_items.ts +++ b/src/state/selectors/get_legend_items.ts @@ -1,8 +1,9 @@ import { GlobalChartState } from '../chart_state'; import { LegendItem } from '../../chart_types/xy_chart/legend/legend'; +import { SeriesKey } from '../../chart_types/xy_chart/utils/series'; -const EMPTY_LEGEND_LIST = new Map(); -export const getLegendItemsSelector = (state: GlobalChartState): Map => { +const EMPTY_LEGEND_LIST = new Map(); +export const getLegendItemsSelector = (state: GlobalChartState): Map => { if (state.internalChartState) { return state.internalChartState.getLegendItems(state); } else { diff --git a/src/state/selectors/get_legend_items_values.ts b/src/state/selectors/get_legend_items_values.ts index 9a41a4f8be..5b06c13136 100644 --- a/src/state/selectors/get_legend_items_values.ts +++ b/src/state/selectors/get_legend_items_values.ts @@ -1,8 +1,9 @@ import { TooltipLegendValue } from '../../chart_types/xy_chart/tooltip/tooltip'; import { GlobalChartState } from '../chart_state'; +import { SeriesKey } from '../../chart_types/xy_chart/utils/series'; -const EMPTY_ITEM_LIST = new Map(); -export const getLegendItemsValuesSelector = (state: GlobalChartState): Map => { +const EMPTY_ITEM_LIST = new Map(); +export const getLegendItemsValuesSelector = (state: GlobalChartState): Map => { if (state.internalChartState) { return state.internalChartState.getLegendItemsValues(state); } else { diff --git a/src/utils/geometry.ts b/src/utils/geometry.ts index 8c0cded6c7..47da1b8342 100644 --- a/src/utils/geometry.ts +++ b/src/utils/geometry.ts @@ -1,6 +1,7 @@ import { $Values } from 'utility-types'; import { BarSeriesStyle, PointStyle, AreaStyle, LineStyle, ArcStyle } from './themes/theme'; import { XYChartSeriesIdentifier } from '../chart_types/xy_chart/utils/series'; +import { Color } from './commons'; /** * The accessor type @@ -31,7 +32,7 @@ export interface PointGeometry { x: number; y: number; radius: number; - color: string; + color: Color; transform: { x: number; y: number; @@ -45,7 +46,7 @@ export interface BarGeometry { y: number; width: number; height: number; - color: string; + color: Color; displayValue?: { text: any; width: number; @@ -61,7 +62,7 @@ export interface BarGeometry { export interface LineGeometry { line: string; points: PointGeometry[]; - color: string; + color: Color; transform: { x: number; y: number; @@ -79,7 +80,7 @@ export interface AreaGeometry { area: string; lines: string[]; points: PointGeometry[]; - color: string; + color: Color; transform: { x: number; y: number; @@ -97,7 +98,7 @@ export interface AreaGeometry { export interface ArcGeometry { arc: string; - color: string; + color: Color; seriesIdentifier: XYChartSeriesIdentifier; seriesArcStyle: ArcStyle; transform: { diff --git a/stories/legend/9_color_picker.tsx b/stories/legend/9_color_picker.tsx new file mode 100644 index 0000000000..f961dcda33 --- /dev/null +++ b/stories/legend/9_color_picker.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; + +import { EuiColorPicker, EuiWrappingPopover, EuiButton, EuiSpacer } from '@elastic/eui'; + +import { Axis, BarSeries, Chart, Position, ScaleType, Settings, LegendColorPicker } from '../../src/'; +import { BARCHART_1Y1G } from '../../src/utils/data_samples/test_dataset'; +import { SeriesKey } from '../../src/chart_types/xy_chart/utils/series'; +import { Color } from '../../src/utils/commons'; + +const onChangeAction = action('onChange'); +const onCloseAction = action('onClose'); + +export const example = () => { + const [colors, setColors] = useState>({}); + + const renderColorPicker: LegendColorPicker = ({ anchor, color, onClose, seriesIdentifier, onChange }) => { + const handleClose = () => { + onClose(); + onCloseAction(); + setColors({ + ...colors, + [seriesIdentifier.key]: color, + }); + }; + const handleChange = (color: Color) => { + onChange(color); + onChangeAction(color); + }; + return ( + + + + + Done + + + ); + }; + + return ( + + + + Number(d).toFixed(2)} /> + + colors[key] ?? null} + /> + + ); +}; + +example.story = { + parameters: { + info: { + text: + 'Elastic charts will maintain the color selection in memory beyond chart updates. However, to persist colors beyond browser refresh the consumer would need to manage the color state and use the color prop on the SeriesSpec to assign a color via a SeriesColorAccessor.', + }, + }, +}; diff --git a/stories/legend/legend.stories.tsx b/stories/legend/legend.stories.tsx index 908565bd29..44dd12573e 100644 --- a/stories/legend/legend.stories.tsx +++ b/stories/legend/legend.stories.tsx @@ -15,3 +15,4 @@ export { example as changingSpecs } from './5_changing_specs'; export { example as hideLegendItemsBySeries } from './6_hide_legend'; export { example as displayValuesInLegendElements } from './7_display_values'; export { example as legendSpacingBuffer } from './8_spacing_buffer'; +export { example as colorPicker } from './9_color_picker'; diff --git a/yarn.lock b/yarn.lock index cc84ed5d16..05c539a8a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2670,6 +2670,13 @@ resolved "https://registry.yarnpkg.com/@egoist/vue-to-react/-/vue-to-react-1.1.0.tgz#83c884b8608e8ee62e76c03e91ce9c26063a91ad" integrity sha512-MwfwXHDh6ptZGLEtNLPXp2Wghteav7mzpT2Mcwl3NZWKF814i5hhHnNkVrcQQEuxUroSWQqzxLkMKSb+nhPang== +"@elastic/datemath@^5.0.2": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@elastic/datemath/-/datemath-5.0.2.tgz#1e62fe7137acd6ebcde9a794ef22b91820c9e6cf" + integrity sha512-MYU7KedGPMYu3ljgrO3tY8I8rD73lvBCltd78k5avDIv/6gMbuhKXsMhkEPbb9angs9hR/2ADk0QcGbVxUBXUw== + dependencies: + tslib "^1.9.3" + "@elastic/eui@^16.0.1": version "16.0.1" resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-16.0.1.tgz#8b3d358d1574f4168fd276c5cb190361c477f0b0"