diff --git a/e2e/screenshots/flame_stories.test.ts-snapshots/flame-stories/should-focus-on-searched-terms-chrome-linux.png b/e2e/screenshots/flame_stories.test.ts-snapshots/flame-stories/should-focus-on-searched-terms-chrome-linux.png new file mode 100644 index 0000000000..e200ad799b Binary files /dev/null and b/e2e/screenshots/flame_stories.test.ts-snapshots/flame-stories/should-focus-on-searched-terms-chrome-linux.png differ diff --git a/e2e/tests/flame_stories.test.ts b/e2e/tests/flame_stories.test.ts index 8e495d386e..2e6aff30af 100644 --- a/e2e/tests/flame_stories.test.ts +++ b/e2e/tests/flame_stories.test.ts @@ -29,4 +29,13 @@ test.describe('Flame stories', () => { }, ); }); + test('should focus on searched terms', async ({ page }) => { + await common.expectChartAtUrlToMatchScreenshot(page)( + `http://localhost:9001/?path=/story/flame-alpha--cpu-profile-g-l-flame-chart&globals=theme:light&knob-Text%20to%20search=gotype`, + { + // replace this with render count selector when render count fixed + delay: 300, // wait for tweening and blinking to finish + }, + ); + }); }); diff --git a/packages/charts/api/charts.api.md b/packages/charts/api/charts.api.md index 7be0a17eb3..50ee4a0224 100644 --- a/packages/charts/api/charts.api.md +++ b/packages/charts/api/charts.api.md @@ -1066,7 +1066,7 @@ export type FitConfig = { }; // @public -export const Flame: (props: SFProps, "chartType" | "specType", "animation" | "valueFormatter" | "valueGetter" | "valueAccessor", never, "id" | "columnarData" | "controlProviderCallback">) => null; +export const Flame: (props: SFProps, "chartType" | "specType", "animation" | "valueFormatter" | "valueGetter" | "valueAccessor", "search" | "onSearchTextChange", "id" | "columnarData" | "controlProviderCallback">) => null; // @public (undocumented) export type FlameElementEvent = FlameLayerValue; @@ -1110,6 +1110,12 @@ export interface FlameSpec extends Spec, LegacyAnim // (undocumented) controlProviderCallback: Partial; // (undocumented) + onSearchTextChange?: (text: string) => void; + // (undocumented) + search?: { + text: string; + }; + // (undocumented) specType: typeof SpecType.Series; // (undocumented) valueAccessor: ValueAccessor; diff --git a/packages/charts/src/chart_types/flame_chart/flame_api.ts b/packages/charts/src/chart_types/flame_chart/flame_api.ts index 70c2caa2e6..9539cb55fa 100644 --- a/packages/charts/src/chart_types/flame_chart/flame_api.ts +++ b/packages/charts/src/chart_types/flame_chart/flame_api.ts @@ -77,6 +77,8 @@ export interface FlameSpec extends Spec, LegacyAnim valueAccessor: ValueAccessor; valueFormatter: ValueFormatter; valueGetter: (datumIndex: number) => number; + search?: { text: string }; + onSearchTextChange?: (text: string) => void; } const buildProps = buildSFProps()( diff --git a/packages/charts/src/chart_types/flame_chart/flame_chart.tsx b/packages/charts/src/chart_types/flame_chart/flame_chart.tsx index 36263d199a..7c4d5b7741 100644 --- a/packages/charts/src/chart_types/flame_chart/flame_chart.tsx +++ b/packages/charts/src/chart_types/flame_chart/flame_chart.tsx @@ -148,6 +148,8 @@ interface StateProps { a11ySettings: ReturnType; tooltipRequired: boolean; canPinTooltip: boolean; + search: NonNullable; + onSeachTextChange: (text: string) => void; onElementOver: NonNullable; onElementClick: NonNullable; onElementOut: NonNullable; @@ -248,6 +250,7 @@ class FlameComponent extends React.Component { throw new Error('flame error: Mismatch between position1 (xy) and label length'); this.targetFocus = this.getFocusOnRoot(); + this.bindControls(); this.currentFocus = { ...this.targetFocus }; @@ -384,6 +387,12 @@ class FlameComponent extends React.Component { * so we could use a couple of ! non-null assertions but no big plus */ this.tryCanvasContext(); + + if (this.props.search.text.length > 0 && this.searchInputRef.current) { + this.searchInputRef.current.value = this.props.search.text; + this.searchForText(false); + } + this.drawCanvas(); this.props.onChartRendered(); this.setupDevicePixelRatioChangeListener(); @@ -403,12 +412,17 @@ class FlameComponent extends React.Component { return this.props.chartDimensions.height !== height || this.props.chartDimensions.width !== width; } - componentDidUpdate = ({ chartDimensions }: FlameProps) => { + componentDidUpdate = ({ chartDimensions, search }: FlameProps) => { if (!this.ctx) this.tryCanvasContext(); if (this.tooltipPinned && this.chartDimensionsChanged(chartDimensions)) { this.unpinTooltip(); } this.bindControls(); + if (search.text !== this.props.search.text && this.searchInputRef.current) { + this.searchInputRef.current.value = this.props.search.text; + this.searchForText(false); + } + this.ensureTextureAndDraw(); // eg. due to chartDimensions (parentDimensions) change @@ -993,7 +1007,10 @@ class FlameComponent extends React.Component { placeholder="Search string" onKeyPress={this.handleSearchFieldKeyPress} onKeyUp={this.handleEscapeKey} - onChange={() => this.searchForText(false)} + onChange={(e) => { + this.searchForText(false); + this.props.onSeachTextChange(e.currentTarget.value); + }} style={{ border: 'none', padding: 3, @@ -1022,6 +1039,7 @@ class FlameComponent extends React.Component { onClick={() => { if (this.currentSearchString && this.searchInputRef.current) { this.clearSearchText(); + this.props.onSeachTextChange(''); } }} style={{ display: 'none' }} @@ -1358,6 +1376,8 @@ const mapStateToProps = (state: GlobalChartState): StateProps => { a11ySettings: getA11ySettingsSelector(state), tooltipRequired: tooltipSpec.type !== TooltipType.None, canPinTooltip: isPinnableTooltip(state), + search: flameSpec?.search ?? { text: '' }, + onSeachTextChange: flameSpec?.onSearchTextChange ?? (() => {}), // mandatory charts API protocol; todo extract these mappings once there are other charts like Flame onElementOver: settingsSpec.onElementOver ?? (() => {}), onElementClick: settingsSpec.onElementClick ?? (() => {}), diff --git a/storybook/stories/icicle/04_cpu_profile_gl_flame.story.tsx b/storybook/stories/icicle/04_cpu_profile_gl_flame.story.tsx index 26e0ee3578..9236e73a86 100644 --- a/storybook/stories/icicle/04_cpu_profile_gl_flame.story.tsx +++ b/storybook/stories/icicle/04_cpu_profile_gl_flame.story.tsx @@ -8,7 +8,7 @@ import { action } from '@storybook/addon-actions'; import { boolean, button, text } from '@storybook/addon-knobs'; -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Chart, @@ -26,9 +26,6 @@ import { getRandomNumberGenerator } from '@elastic/charts/src/mocks/utils'; import { ChartsStory } from '../../types'; import { useBaseTheme } from '../../use_base_theme'; -const position = new Float32Array(columnarMock.position); -const size = new Float32Array(columnarMock.size); - const rng = getRandomNumberGenerator(); const paletteColorBrewerCat12 = [ @@ -46,6 +43,9 @@ const paletteColorBrewerCat12 = [ [255, 237, 111], ]; +const position = new Float32Array(columnarMock.position); +const size = new Float32Array(columnarMock.size); + const columnarData: ColumnarViewModel = { label: columnarMock.label.map((index: number) => columnarMock.dictionary[index]), // reversing the dictionary encoding value: new Float64Array(columnarMock.value), @@ -53,9 +53,9 @@ const columnarData: ColumnarViewModel = { color: new Float32Array( columnarMock.label.flatMap(() => [...paletteColorBrewerCat12[rng(0, 11)].map((c) => c / 255), 1]), ), - position0: position.map((p, i) => (i % 2 === 0 ? 1 - p - size[i / 2] : p)), //.map((p, i) => (i % 2 === 0 ? 1 - p - size[i / 2] : p)), // new Float32Array([...position].slice(1)), // try with the wrong array length + position0: position, //position.map((p, i) => (i % 2 === 0 ? 1 - p - size[i / 2] : p)), //.map((p, i) => (i % 2 === 0 ? 1 - p - size[i / 2] : p)), // new Float32Array([...position].slice(1)), // try with the wrong array length position1: position, - size0: size.map((s) => 0.8 * s), + size0: size, //size.map((s) => 0.8 * s), size1: size, }; @@ -64,7 +64,6 @@ const noop = () => {}; export const Example: ChartsStory = (_, { title, description }) => { let resetFocusControl: FlameGlobalControl = noop; // initial value let focusOnNodeControl: FlameNodeControl = noop; // initial value - let searchText: FlameSearchControl = noop; // initial value const onElementListeners = { onElementClick: action('onElementClick'), @@ -77,10 +76,9 @@ export const Example: ChartsStory = (_, { title, description }) => { button('Set focus on random node', () => { focusOnNodeControl(rng(0, 19)); }); - const textSearch = text('Text to search', 'github'); - button('Search', () => { - searchText(textSearch); - }); + const textSearch = text('Text to search', ''); + const textChangeAction = action('Text change'); + const debug = boolean('Debug history', false); return ( @@ -91,10 +89,11 @@ export const Example: ChartsStory = (_, { title, description }) => { valueAccessor={(d: Datum) => d.value as number} valueFormatter={(value) => `${value}`} animation={{ duration: 500 }} + search={{ text: textSearch }} + onSearchTextChange={(text) => textChangeAction(`text changed to: [${text}]`)} controlProviderCallback={{ resetFocus: (control) => (resetFocusControl = control), focusOnNode: (control) => (focusOnNodeControl = control), - search: (control) => (searchText = control), }} />