diff --git a/examples/visualization_examples/README.md b/examples/visualization_examples/README.md new file mode 100644 index 000000000000..58599907beeb --- /dev/null +++ b/examples/visualization_examples/README.md @@ -0,0 +1,129 @@ +# Visualization Examples + +This plugin provides examples of how to use the Visualization component from the explore plugin in OpenSearch Dashboards. + +## Overview + +The Visualization component allows you to easily render different types of visualizations (charts, tables, metrics, etc.) in your OpenSearch Dashboards plugin. This example plugin demonstrates how to use the Visualization component with different chart types and data. + +## Features + +- Examples of all supported chart types: + + - Bar Chart + - Line Chart + - Pie Chart + - Metric + - Heatmap + - Area Chart + - Scatter Plot + - Gauge + - Table + +- Interactive UI to switch between different chart types +- Sample data for each chart type +- Usage documentation + +## Getting Started + +### Prerequisites + +- OpenSearch Dashboards development environment + +### Running the Examples + +1. Clone the OpenSearch Dashboards repository +2. Navigate to the OpenSearch Dashboards directory +3. Install dependencies: `yarn` +4. Start OpenSearch Dashboards in development mode: `yarn start` +5. Access the examples at: `http://localhost:5601/app/visualizationExamples` + +## Usage + +To use the Visualization component in your own plugin: + +1. Add the explore plugin as a dependency in your plugin's `opensearch_dashboards.json`: + +```json +{ + "requiredPlugins": ["explore"] +} +``` + +2. Access the Visualization component from the explore plugin's setup contract: + +```typescript +// In your plugin's setup method +public setup(core: CoreSetup, { explore }: SetupDeps) { + const { ui } = explore; + const { Visualization } = ui; + + // Now you can use the Visualization component in your React components +} +``` + +3. Use the Visualization component in your React components: + +```tsx +// Sample data +const data = [ + { category: 'A', value: 10 }, + { category: 'B', value: 20 }, + { category: 'C', value: 15 }, +]; + +// Render the visualization +; +``` + +## Chart Types + +The Visualization component supports the following chart types: + +- `bar`: Bar chart +- `line`: Line chart +- `pie`: Pie chart +- `metric`: Metric visualization +- `heatmap`: Heatmap +- `area`: Area chart +- `scatter`: Scatter plot +- `gauge`: Gauge chart +- `table`: Table visualization + +## Data Format + +The data format depends on the chart type you're using. Here are some examples: + +### Bar Chart + +```typescript +const barData = [ + { category: 'A', value: 10 }, + { category: 'B', value: 20 }, + { category: 'C', value: 15 }, +]; +``` + +### Line Chart + +```typescript +const lineData = [ + { date: '2023-01-01', value: 10 }, + { date: '2023-02-01', value: 15 }, + { date: '2023-03-01', value: 7 }, +]; +``` + +### Pie Chart + +```typescript +const pieData = [ + { category: 'Category A', value: 30 }, + { category: 'Category B', value: 25 }, + { category: 'Category C', value: 15 }, +]; +``` + +## License + +This code is licensed under the Apache License 2.0. diff --git a/examples/visualization_examples/opensearch_dashboards.json b/examples/visualization_examples/opensearch_dashboards.json new file mode 100644 index 000000000000..5a9c50c366e6 --- /dev/null +++ b/examples/visualization_examples/opensearch_dashboards.json @@ -0,0 +1,8 @@ +{ + "id": "visualizationExamples", + "version": "1.0.0", + "opensearchDashboardsVersion": "opensearchDashboards", + "server": false, + "ui": true, + "requiredPlugins": ["developerExamples", "expressions", "explore"] +} diff --git a/examples/visualization_examples/public/app.tsx b/examples/visualization_examples/public/app.tsx new file mode 100644 index 000000000000..644b871716c4 --- /dev/null +++ b/examples/visualization_examples/public/app.tsx @@ -0,0 +1,265 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import ReactDOM from 'react-dom'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, + EuiText, + EuiSpacer, + EuiPanel, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, +} from '@elastic/eui'; +import { CoreStart } from '../../../src/core/public'; +import { ExpressionsSetup, ExpressionsStart } from '../../../src/plugins/expressions/public'; +import { ExplorePluginSetup, ExplorePluginStart } from '../../../src/plugins/explore/public'; + +// Define ChartType locally to avoid restricted imports +type ChartType = + | 'line' + | 'pie' + | 'metric' + | 'heatmap' + | 'scatter' + | 'bar' + | 'area' + | 'table' + | 'gauge'; + +interface RenderDependencies { + core: CoreStart; + deps: { + explore: ExplorePluginStart; + }; + expressions: ExpressionsStart; + appBasePath: string; + element: HTMLElement; + Visualization: ExplorePluginSetup['ui']['Visualization']; +} + +// Sample data for visualizations +const sampleData = { + barData: [ + { category: 'A', value: 10 }, + { category: 'B', value: 20 }, + { category: 'C', value: 15 }, + { category: 'D', value: 25 }, + { category: 'E', value: 18 }, + ], + lineData: [ + { date: '2023-01-01', value: 10 }, + { date: '2023-02-01', value: 15 }, + { date: '2023-03-01', value: 7 }, + { date: '2023-04-01', value: 20 }, + { date: '2023-05-01', value: 12 }, + { date: '2023-06-01', value: 30 }, + ], + pieData: [ + { category: 'Category A', value: 30 }, + { category: 'Category B', value: 25 }, + { category: 'Category C', value: 15 }, + { category: 'Category D', value: 10 }, + { category: 'Category E', value: 20 }, + ], + metricData: [{ value: 1250 }], + heatmapData: [ + { x: 'A', y: '1', value: 10 }, + { x: 'A', y: '2', value: 20 }, + { x: 'A', y: '3', value: 30 }, + { x: 'B', y: '1', value: 40 }, + { x: 'B', y: '2', value: 50 }, + { x: 'B', y: '3', value: 60 }, + { x: 'C', y: '1', value: 70 }, + { x: 'C', y: '2', value: 80 }, + { x: 'C', y: '3', value: 90 }, + ], + areaData: [ + { date: '2023-01-01', value: 10 }, + { date: '2023-02-01', value: 15 }, + { date: '2023-03-01', value: 7 }, + { date: '2023-04-01', value: 20 }, + { date: '2023-05-01', value: 12 }, + { date: '2023-06-01', value: 30 }, + ], + scatterData: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + { x: 3, y: 15 }, + { x: 4, y: 25 }, + { x: 5, y: 18 }, + { x: 6, y: 30 }, + { x: 7, y: 12 }, + { x: 8, y: 22 }, + ], + gaugeData: [{ value: 75 }], + tableData: [ + { name: 'John', age: 30, city: 'New York' }, + { name: 'Jane', age: 25, city: 'San Francisco' }, + { name: 'Bob', age: 40, city: 'Chicago' }, + { name: 'Alice', age: 35, city: 'Seattle' }, + { name: 'Tom', age: 28, city: 'Boston' }, + ], +}; + +// Example component to demonstrate the Visualization component +const VisualizationExample = ({ + Visualization, +}: { + Visualization: RenderDependencies['Visualization']; +}) => { + const [chartType, setChartType] = useState('bar'); + + // Get the appropriate data for the selected chart type + const getDataForChartType = (type: ChartType) => { + switch (type) { + case 'bar': + return sampleData.barData; + case 'line': + return sampleData.lineData; + case 'pie': + return sampleData.pieData; + case 'metric': + return sampleData.metricData; + case 'heatmap': + return sampleData.heatmapData; + case 'area': + return sampleData.areaData; + case 'scatter': + return sampleData.scatterData; + case 'gauge': + return sampleData.gaugeData; + case 'table': + return sampleData.tableData; + default: + return sampleData.barData; + } + }; + + // Generate example code based on the current chart type + const getExampleCode = () => { + const data = JSON.stringify(getDataForChartType(chartType), null, 2) + .split('\n') + .map((line, i) => (i === 0 ? line : ` ${line}`)) + .join('\n'); + + return `// Import the Visualization component from the explore plugin setup +// In your plugin's setup method: +const { ui } = setupDeps.explore; +const { Visualization } = ui; + +// Sample data for ${chartType} chart +const data = ${data}; + +// Render the visualization +`; + }; + + const chartOptions = [ + { value: 'bar', text: 'Bar Chart' }, + { value: 'line', text: 'Line Chart' }, + { value: 'pie', text: 'Pie Chart' }, + { value: 'metric', text: 'Metric' }, + { value: 'heatmap', text: 'Heatmap' }, + { value: 'area', text: 'Area Chart' }, + { value: 'scatter', text: 'Scatter Plot' }, + { value: 'gauge', text: 'Gauge' }, + { value: 'table', text: 'Table' }, + ]; + + return ( + + + + + +

Visualization Examples

+
+
+
+ + + +

+ This example demonstrates how to use the Visualization component from the explore + plugin. You can select different chart types from the dropdown below to see how the + component renders different visualizations. +

+
+ + + + + setChartType(e.target.value as ChartType)} + aria-label="Select chart type" + /> + + + + + + + +

{chartOptions.find((option) => option.value === chartType)?.text}

+
+ +
+ +
+
+ + + + +

Usage

+

The Visualization component takes two props:

+
    +
  • + data: An array of records containing the data to visualize +
  • +
  • + type: (Optional) The chart type to render +
  • +
+

Example usage:

+
{getExampleCode()}
+
+
+
+
+
+ ); +}; + +export const renderApp = ({ + core, + deps, + expressions, + appBasePath, + element, + Visualization, +}: RenderDependencies) => { + ReactDOM.render(, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/visualization_examples/public/index.ts b/examples/visualization_examples/public/index.ts new file mode 100644 index 000000000000..02d01f1aac62 --- /dev/null +++ b/examples/visualization_examples/public/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginInitializerContext } from '../../../src/core/public'; +import { VisualizationExamplesPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, the plugin initialization function +export function plugin(initializerContext: PluginInitializerContext) { + return new VisualizationExamplesPlugin(); +} + +export { VisualizationExamplesPlugin }; diff --git a/examples/visualization_examples/public/plugin.ts b/examples/visualization_examples/public/plugin.ts new file mode 100644 index 000000000000..7f69cf27b3fe --- /dev/null +++ b/examples/visualization_examples/public/plugin.ts @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CoreSetup, + Plugin, + AppMountParameters, + AppNavLinkStatus, + DEFAULT_APP_CATEGORIES, +} from '../../../src/core/public'; +import { DeveloperExamplesSetup } from '../../developer_examples/public'; +import { ExpressionsSetup, ExpressionsStart } from '../../../src/plugins/expressions/public'; +import { ExplorePluginSetup, ExplorePluginStart } from '../../../src/plugins/explore/public'; + +interface SetupDeps { + developerExamples: DeveloperExamplesSetup; + expressions: ExpressionsSetup; + explore: ExplorePluginSetup; +} + +interface StartDeps { + explore: ExplorePluginStart; + expressions: ExpressionsStart; +} + +export class VisualizationExamplesPlugin implements Plugin { + public setup(core: CoreSetup, { developerExamples, expressions, explore }: SetupDeps) { + // Register an application that will be listed in the left navigation + // under the "Visualizations Examples" section + core.application.register({ + id: 'visualizationExamples', + title: 'Visualization Examples', + order: 8000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + // Load application bundle + const { renderApp } = await import('./app'); + // Get start services + const [coreStart, depsStart] = await core.getStartServices(); + // Get the Visualization component from the explore plugin + const { Visualization } = explore.ui; + // Render the application + return renderApp({ + core: coreStart, + deps: depsStart, + expressions: depsStart.expressions, + appBasePath: params.appBasePath, + element: params.element, + Visualization, + }); + }, + }); + + // Register with the developer examples plugin + developerExamples.register({ + appId: 'visualizationExamples', + title: 'Visualization Examples', + description: 'Examples of using the Visualization component from the explore plugin.', + links: [ + { + label: 'Visualization Component', + href: + 'https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/plugins/explore/public/components/visualizations/visualization.tsx', + iconType: 'logoGithub', + target: '_blank', + }, + ], + }); + } + + public start() {} + + public stop() {} +} diff --git a/packages/osd-storybook/webpack.config.ts b/packages/osd-storybook/webpack.config.ts index bc2a5e35f358..75ed524de402 100644 --- a/packages/osd-storybook/webpack.config.ts +++ b/packages/osd-storybook/webpack.config.ts @@ -145,6 +145,7 @@ export default function ({ config: storybookConfig }: { config: Configuration }) extensions: ['.scss'], alias: { core_app_image_assets: resolve(REPO_ROOT, 'src/core/public/core_app/images'), + 'opensearch-dashboards/public': resolve(REPO_ROOT, 'src/core/public'), }, }, stats, diff --git a/src/plugins/explore/public/components/visualizations/utils/normalize_result_rows.ts b/src/plugins/explore/public/components/visualizations/utils/normalize_result_rows.ts index 6db1d24fab3f..7fd3ac0c0d9f 100644 --- a/src/plugins/explore/public/components/visualizations/utils/normalize_result_rows.ts +++ b/src/plugins/explore/public/components/visualizations/utils/normalize_result_rows.ts @@ -7,43 +7,86 @@ import { OpenSearchSearchHit } from '../../../types/doc_views_types'; import { FIELD_TYPE_MAP } from '../constants'; import { VisColumn, VisFieldType } from '../types'; -export const normalizeResultRows = ( +interface FieldType { + name: string; + type: VisFieldType; +} + +export const normalizeSearchHits = ( rows: Array>, schema: Array<{ type?: string; name?: string }> ) => { - const columns: VisColumn[] = schema.map((field, index) => { - return { - id: index, - schema: FIELD_TYPE_MAP[field.type || ''] || VisFieldType.Unknown, - name: field.name || '', - column: `field-${index}`, - validValuesCount: 0, - uniqueValuesCount: 0, - }; - }); - const transformedData = rows.map((row: OpenSearchSearchHit) => { const transformedRow: Record = {}; - for (const column of columns) { - // Type assertion for _source since it's marked as unknown - const source = row._source as Record; - transformedRow[column.column] = source[column.name]; + for (const s of schema) { + if (s.name) { + // Type assertion for _source since it's marked as unknown + const source = row._source as Record; + transformedRow[s.name] = source[s.name]; + } } return transformedRow; }); + return transformedData; +}; - // count validValues and uniqueValues - const columnsWithStats: VisColumn[] = columns.map((column) => { - const values = transformedData.map((row) => row[column.column]); +export const normalizeSearchHitsSchema = (schema: Array<{ type?: string; name?: string }>) => { + const typeHints: Record = {}; + for (const s of schema) { + if (s.name) { + const type = s.type && FIELD_TYPE_MAP[s.type]; + typeHints[s.name] = type ? type : VisFieldType.Unknown; + } + } + return typeHints; +}; + +export const getDataColumns = ( + rows: Array>, + typeHints: Record = {} +) => { + const columns: Record = {}; + const hints = { ...typeHints }; + + for (const row of rows) { + Object.keys(row).forEach((name) => { + let type = hints[name] ?? VisFieldType.Unknown; + + if (type === VisFieldType.Unknown) { + const valuePrimitiveType = typeof row[name]; + if (valuePrimitiveType === 'string' || valuePrimitiveType === 'boolean') { + type = VisFieldType.Categorical; + } else if (valuePrimitiveType === 'number' || valuePrimitiveType === 'bigint') { + type = VisFieldType.Numerical; + } + hints[name] = type; + } + + if (!columns[name]) { + columns[name] = { name, type }; + } + }); + } + return columns; +}; + +export const getColumnStats = ( + columns: Record, + rows: Array> +) => { + const columnsWithStats: VisColumn[] = Object.values(columns).map((field, index) => { + const values = rows.map((row) => row[field.name]); const validValues = values.filter((v) => v !== null && v !== undefined); const uniqueValues = new Set(validValues); return { - ...column, + id: index, + name: field.name, + schema: field.type, + column: `field-${index}`, validValuesCount: validValues.length ?? 0, uniqueValuesCount: uniqueValues.size ?? 0, }; }); - const numericalColumns = columnsWithStats.filter( (column) => column.schema === VisFieldType.Numerical ); @@ -52,5 +95,22 @@ export const normalizeResultRows = ( ); const dateColumns = columnsWithStats.filter((column) => column.schema === VisFieldType.Date); - return { transformedData, numericalColumns, categoricalColumns, dateColumns }; + const transformedData = rows.map((row) => { + const transformedRow: Record = {}; + for (const column of columnsWithStats) { + transformedRow[column.column] = row[column.name]; + } + return transformedRow; + }); + + return { numericalColumns, categoricalColumns, dateColumns, transformedData }; +}; + +export const normalizeResultRows = ( + rows: Array>, + schema: Array<{ type?: string; name?: string }> +) => { + const data = normalizeSearchHits(rows, schema); + const typeHints = normalizeSearchHitsSchema(schema); + return getColumnStats(getDataColumns(data, typeHints), rows); }; diff --git a/src/plugins/explore/public/components/visualizations/utils/to_expression.test.ts b/src/plugins/explore/public/components/visualizations/utils/to_expression.test.ts index 6e504a41c6ac..d69123257cc2 100644 --- a/src/plugins/explore/public/components/visualizations/utils/to_expression.test.ts +++ b/src/plugins/explore/public/components/visualizations/utils/to_expression.test.ts @@ -41,12 +41,12 @@ describe('to_expression', () => { jest.clearAllMocks(); }); - it('should return an empty string if searchContext is not provided', async () => { + it('should still return an expression string if searchContext is not provided', async () => { const result = toExpression(undefined as any, {}); - expect(result).toBe(''); - expect(expressionsPublic.buildExpressionFunction).not.toHaveBeenCalled(); - expect(expressionsPublic.buildExpression).not.toHaveBeenCalled(); + expect(result).toBe('mocked_expression_string'); + expect(expressionsPublic.buildExpressionFunction).toHaveBeenCalled(); + expect(expressionsPublic.buildExpression).toHaveBeenCalled(); }); it('should build and return an expression string when all required parameters are provided', async () => { diff --git a/src/plugins/explore/public/components/visualizations/utils/to_expression.ts b/src/plugins/explore/public/components/visualizations/utils/to_expression.ts index 1cd93b858781..257e379a5b39 100644 --- a/src/plugins/explore/public/components/visualizations/utils/to_expression.ts +++ b/src/plugins/explore/public/components/visualizations/utils/to_expression.ts @@ -22,7 +22,7 @@ export const toExpression = ( spec: Record ) => { if (!searchContext) { - return ''; + searchContext = {}; } spec.config = { diff --git a/src/plugins/explore/public/components/visualizations/utils/use_visualization_types.ts b/src/plugins/explore/public/components/visualizations/utils/use_visualization_types.ts index 2458a5e38837..75ca7d49b7c8 100644 --- a/src/plugins/explore/public/components/visualizations/utils/use_visualization_types.ts +++ b/src/plugins/explore/public/components/visualizations/utils/use_visualization_types.ts @@ -52,6 +52,18 @@ export type StyleOptions = | TableChartStyleOptions | GaugeChartStyleOptions; +export interface StyleOptionsMapping { + line: LineChartStyleOptions; + pie: PieChartStyleOptions; + metric: MetricChartStyleOptions; + heatmap: HeatmapChartStyleOptions; + scatter: ScatterChartStyleOptions; + bar: BarChartStyleOptions; + area: AreaChartStyleOptions; + table: TableChartStyleOptions; + gauge: GaugeChartStyleOptions; +} + export type ChartStyles = ChartStylesMapping[ChartType]; export interface StyleControlsProps { diff --git a/src/plugins/explore/public/components/visualizations/visualization.tsx b/src/plugins/explore/public/components/visualizations/visualization.tsx new file mode 100644 index 000000000000..8389068eea4d --- /dev/null +++ b/src/plugins/explore/public/components/visualizations/visualization.tsx @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useRef } from 'react'; +import { VisualizationBuilder } from './visualization_builder'; +import { StyleOptionsMapping, ChartType } from './utils/use_visualization_types'; +import { getExpressions } from '../../services/services'; +import { AxisRole } from './types'; +import { ChartConfig } from './visualization_builder.types'; + +interface Props extends React.HTMLAttributes { + data: Array>; + type: T; + fields?: Array<{ name: string; role: AxisRole }>; + options?: StyleOptionsMapping[T]; +} + +export const Visualization = ({ + data, + type, + fields, + options, + ...divProps +}: Props) => { + const visualizationBuilderRef = useRef( + new VisualizationBuilder({ + getExpressions: () => getExpressions(), + }) + ); + + useEffect(() => { + const visualizationBuilder = visualizationBuilderRef.current; + visualizationBuilder.init(); + return () => { + visualizationBuilder.reset(); + }; + }, []); + + useEffect(() => { + const visualizationBuilder = visualizationBuilderRef.current; + const config: ChartConfig = { type }; + + if (fields) { + const mapping: Record = {}; + for (const f of fields) { + mapping[f.role] = f.name; + } + config.axesMapping = mapping; + } + + if (options) { + config.styles = options; + } + + visualizationBuilder.setVisConfig(config); + visualizationBuilder.handleData(data); + }, [data, type, fields, options]); + + return
{visualizationBuilderRef.current.renderVisualization()}
; +}; diff --git a/src/plugins/explore/public/components/visualizations/visualization_builder.test.ts b/src/plugins/explore/public/components/visualizations/visualization_builder.test.ts index 5aa0567b75c8..109f4c067d82 100644 --- a/src/plugins/explore/public/components/visualizations/visualization_builder.test.ts +++ b/src/plugins/explore/public/components/visualizations/visualization_builder.test.ts @@ -456,13 +456,10 @@ describe('VisualizationBuilder', () => { const builder = new VisualizationBuilder({ getExpressions: () => expressionsPluginMock.createStartContract(), }); - builder.handleData( - [{ _id: '_id', _index: '_index', _score: 10, _source: { age: 10, name: 'name' } }], - [ - { type: 'int', name: 'age' }, - { type: 'text', name: 'name' }, - ] - ); + builder.handleData([{ age: 10, name: 'name' }], { + name: VisFieldType.Categorical, + age: VisFieldType.Numerical, + }); expect(builder.data$.value).toEqual({ categoricalColumns: [ { @@ -556,13 +553,10 @@ describe('VisualizationBuilder', () => { styles: { addLegend: true } as any, axesMapping: { x: 'name', y: 'age' }, }); - builder.handleData( - [{ _id: '_id', _index: '_index', _score: 10, _source: { age: 10, name: 'name' } }], - [ - { type: 'int', name: 'age' }, - { type: 'text', name: 'name' }, - ] - ); + builder.handleData([{ age: 10, name: 'name' }], { + name: VisFieldType.Numerical, + age: VisFieldType.Categorical, + }); expect(builder.data$.value).not.toBe(undefined); expect(builder.visConfig$.value).not.toBe(undefined); diff --git a/src/plugins/explore/public/components/visualizations/visualization_builder.ts b/src/plugins/explore/public/components/visualizations/visualization_builder.ts index 9ee3b2c92a06..b13e946a5d8b 100644 --- a/src/plugins/explore/public/components/visualizations/visualization_builder.ts +++ b/src/plugins/explore/public/components/visualizations/visualization_builder.ts @@ -10,12 +10,11 @@ import { debounceTime, map } from 'rxjs/operators'; import { ChartStyles, ChartType, StyleOptions } from './utils/use_visualization_types'; import { convertMappingsToStrings, isValidMapping } from './visualization_builder_utils'; -import { getServices } from '../../services/services'; +import { getExpressions as getExpressionsService, getServices } from '../../services/services'; import { IOsdUrlStateStorage } from '../../../../opensearch_dashboards_utils/public'; -import { OpenSearchSearchHit } from '../../types/doc_views_types'; import { isChartType } from './utils/is_chart_type'; import { visualizationRegistry } from './visualization_registry'; -import { normalizeResultRows } from './utils/normalize_result_rows'; +import { getColumnStats, getDataColumns } from './utils/normalize_result_rows'; import { ChartConfig, VisData } from './visualization_builder.types'; import { ExecutionContextSearch } from '../../../../expressions/common/'; import { VisualizationRender } from './visualization_render'; @@ -23,7 +22,7 @@ import { ExpressionsStart } from '../../../../expressions/public'; import { StylePanelRender } from './style_panel_render'; import { adaptLegacyData } from './visualization_builder_utils'; import { mergeStyles } from './utils/utils'; -import { RenderChartConfig } from './types'; +import { RenderChartConfig, VisFieldType } from './types'; interface VisState { styleOptions?: StyleOptions; @@ -33,7 +32,7 @@ interface VisState { interface Options { getUrlStateStorage?: () => IOsdUrlStateStorage | undefined; - getExpressions: () => ExpressionsStart; + getExpressions?: () => ExpressionsStart; } export class VisualizationBuilder { @@ -127,7 +126,7 @@ export class VisualizationBuilder { // Always reset style after changing chart type if (currentVisConfig?.type !== chartType) { - newVisConfig.styles = visConfig.ui.style.defaults; + newVisConfig.styles = undefined; } // Table chart doesn't have axes mapping, but we need to keep current axes mapping, so when switch back to other types @@ -250,13 +249,12 @@ export class VisualizationBuilder { // For these cases, we will create auto vis based on the rules. If not auto vis can be created, // reset chart type and axes mapping to empty, this will let user to choose. if (isEmpty(axesMapping) || !isValidMapping(axesMapping ?? {}, columns)) { - const autoVis = this.createAutoVis(data); + const autoVis = this.createAutoVis(data, currentChartType); if (autoVis) { const chartTypeConfig = visualizationRegistry.getVisualizationConfig(autoVis.chartType); if (chartTypeConfig) { const newVisConfig: ChartConfig = { type: autoVis.chartType, - styles: chartTypeConfig.ui.style.defaults, axesMapping: autoVis.axesMapping, }; this.setVisConfig(newVisConfig); @@ -269,7 +267,6 @@ export class VisualizationBuilder { // Default to show a table if no auto vis created const newVisConfig: ChartConfig = { type: 'table', - styles: chartTypeConfig?.ui.style.defaults, }; this.setVisConfig(newVisConfig); } @@ -287,15 +284,13 @@ export class VisualizationBuilder { } handleData( - rows: Array>, - schema: Array<{ type?: string; name?: string }> + rows: Array>, + typeHints?: Record ) { - const { - transformedData, - numericalColumns, - categoricalColumns, - dateColumns, - } = normalizeResultRows(rows, schema); + const { numericalColumns, categoricalColumns, dateColumns, transformedData } = getColumnStats( + getDataColumns(rows, typeHints), + rows + ); this.data$.next({ transformedData, numericalColumns, categoricalColumns, dateColumns }); } @@ -304,12 +299,10 @@ export class VisualizationBuilder { if (!currentVisConfig) { return; } - if (currentVisConfig.styles) { - this.visConfig$.next({ - ...currentVisConfig, - styles: { ...currentVisConfig.styles, ...styles }, - }); - } + this.visConfig$.next({ + ...currentVisConfig, + styles: { ...currentVisConfig.styles, ...styles }, + }); } setVisConfig(config?: ChartConfig) { @@ -374,8 +367,8 @@ export class VisualizationBuilder { ); } - renderVisualization({ searchContext }: { searchContext?: ExecutionContextSearch }) { - const ExpressionRenderer = this.getExpression()?.ReactExpressionRenderer; + renderVisualization({ searchContext }: { searchContext?: ExecutionContextSearch } = {}) { + const ExpressionRenderer = this.getExpression?.()?.ReactExpressionRenderer; if (!ExpressionRenderer) { return null; } @@ -407,7 +400,7 @@ export const getVisualizationBuilder = () => { if (!visualizationBuilder) { visualizationBuilder = new VisualizationBuilder({ getUrlStateStorage: () => getServices().osdUrlStateStorage, - getExpressions: () => getServices().expressions, + getExpressions: () => getExpressionsService(), }); } return visualizationBuilder; diff --git a/src/plugins/explore/public/components/visualizations/visualization_container.tsx b/src/plugins/explore/public/components/visualizations/visualization_container.tsx index eae82fca2846..1fad817936e6 100644 --- a/src/plugins/explore/public/components/visualizations/visualization_container.tsx +++ b/src/plugins/explore/public/components/visualizations/visualization_container.tsx @@ -7,11 +7,11 @@ import './visualization_container.scss'; import { EuiPanel } from '@elastic/eui'; import React, { useEffect, useMemo } from 'react'; -import './visualization_container.scss'; import { AxisColumnMappings } from './types'; import { useTabResults } from '../../application/utils/hooks/use_tab_results'; import { useSearchContext } from '../query_panel/utils/use_search_context'; import { getVisualizationBuilder } from './visualization_builder'; +import { normalizeSearchHits, normalizeSearchHitsSchema } from './utils/normalize_result_rows'; export interface UpdateVisualizationProps { mappings: AxisColumnMappings; @@ -42,7 +42,9 @@ export const VisualizationContainer = () => { const visualizationBuilder = getVisualizationBuilder(); useEffect(() => { - visualizationBuilder.handleData(rows, fieldSchema); + const data = normalizeSearchHits(rows, fieldSchema); + const typeHints = normalizeSearchHitsSchema(fieldSchema); + visualizationBuilder.handleData(data, typeHints); }, [rows, fieldSchema, visualizationBuilder]); useEffect(() => { diff --git a/src/plugins/explore/public/components/visualizations/visualization_render.test.tsx b/src/plugins/explore/public/components/visualizations/visualization_render.test.tsx index 85eb33def555..6b15a8262466 100644 --- a/src/plugins/explore/public/components/visualizations/visualization_render.test.tsx +++ b/src/plugins/explore/public/components/visualizations/visualization_render.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { BehaviorSubject } from 'rxjs'; import { VisualizationRender } from './visualization_render'; -import { VisData, ChartConfig } from './visualization_builder.types'; +import { VisData } from './visualization_builder.types'; import { VisFieldType, Positions, RenderChartConfig } from './types'; import { ExecutionContextSearch } from '../../../../expressions/common/'; import { defaultBarChartStyles } from './bar/bar_vis_config'; diff --git a/src/plugins/explore/public/components/visualizations/visualization_render.tsx b/src/plugins/explore/public/components/visualizations/visualization_render.tsx index d6c5142beddb..62fedffa6634 100644 --- a/src/plugins/explore/public/components/visualizations/visualization_render.tsx +++ b/src/plugins/explore/public/components/visualizations/visualization_render.tsx @@ -64,10 +64,16 @@ export const VisualizationRender = (props: Props) => { return; } + const vis = visualizationRegistry.getVisualizationConfig(visConfig.type); + if (!vis) { + return; + } + const rule = visualizationRegistry.findRuleByAxesMapping(visConfig?.axesMapping ?? {}, columns); if (!rule || !rule.toSpec) { return; } + const axisColumnMappings = convertStringsToMappings(visConfig?.axesMapping ?? {}, columns); return rule.toSpec( visualizationData.transformedData, diff --git a/src/plugins/explore/public/plugin.ts b/src/plugins/explore/public/plugin.ts index 7872a1bfd9da..d30d7c04236f 100644 --- a/src/plugins/explore/public/plugin.ts +++ b/src/plugins/explore/public/plugin.ts @@ -69,7 +69,8 @@ import { DASHBOARD_ADD_PANEL_TRIGGER } from '../../dashboard/public'; import { createAbortDataQueryAction } from './application/utils/state_management/actions/abort_controller'; import { ABORT_DATA_QUERY_TRIGGER } from '../../ui_actions/public'; import { abortAllActiveQueries } from './application/utils/state_management/actions/query_actions'; -import { setServices } from './services/services'; +import { setExpressions, setServices } from './services/services'; +import { Visualization } from './components/visualizations/visualization'; export class ExplorePlugin implements @@ -445,6 +446,9 @@ export class ExplorePlugin }, visualizationRegistry: visualizationRegistryService, queryPanelActionsRegistry: this.queryPanelActionsRegistryService.setup(), + ui: { + Visualization, + }, }; } @@ -456,6 +460,7 @@ export class ExplorePlugin if (plugins.expressions) { setExpressionLoader(plugins.expressions.ExpressionLoader); + setExpressions(plugins.expressions); } this.initializeServices = () => { diff --git a/src/plugins/explore/public/services/services.ts b/src/plugins/explore/public/services/services.ts index f0bf06aa1a88..828801548efb 100644 --- a/src/plugins/explore/public/services/services.ts +++ b/src/plugins/explore/public/services/services.ts @@ -3,7 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ExpressionsStart } from '../../../../plugins/expressions/public'; import { createGetterSetter } from '../../../opensearch_dashboards_utils/public'; import { ExploreServices } from '../types'; export const [getServices, setServices] = createGetterSetter('ExploreServices'); + +export const [getExpressions, setExpressions] = createGetterSetter('expressions'); diff --git a/src/plugins/explore/public/types.ts b/src/plugins/explore/public/types.ts index 865e04beffcb..dbac9845534e 100644 --- a/src/plugins/explore/public/types.ts +++ b/src/plugins/explore/public/types.ts @@ -53,6 +53,7 @@ import { QueryPanelActionsRegistryService, QueryPanelActionsRegistryServiceSetup, } from './services/query_panel_actions_registry'; +import { Visualization } from './components/visualizations/visualization'; // ============================================================================ // PLUGIN INTERFACES - What Explore provides to other plugins @@ -67,6 +68,9 @@ export interface ExplorePluginSetup { docViewsLinks: { addDocViewLink: (docViewLinkSpec: unknown) => void; }; + ui: { + Visualization: typeof Visualization; + }; } export interface ExplorePluginStart {