diff --git a/CHANGELOG.md b/CHANGELOG.md index e859e023e260..431287f036e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multi DataSource] Update MD data source documentation link ([#2693](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2693)) - [Save Object Aggregation View] Add extension point in saved object management to register namespaces and show filter ([#2656](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2656)) - [Save Object Aggregation View] Fix for export all after scroll count response changed in PR#2656 ([#2696](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2696)) +- [Vis Builder] Add an experimental table visualization in vis builder ([#2705](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2705)) ### πŸ› Bug Fixes @@ -52,7 +53,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multi DataSource] Address UX comments on index pattern management stack ([#2611](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2611)) - [Multi DataSource] Apply get indices error handling in step index pattern ([#2652](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2652)) - [Vis Builder] Last Updated Timestamp for visbuilder savedobject is getting Generated ([#2628](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2628)) -- Removed Leftover X Pack references ([#2638](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2638)) +- Removed Leftover X Pack references ([#2638](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2638)) ### 🚞 Infrastructure diff --git a/src/plugins/data/common/field_formats/field_format.ts b/src/plugins/data/common/field_formats/field_format.ts index 8bff51d1f16c..c5c945f1b899 100644 --- a/src/plugins/data/common/field_formats/field_format.ts +++ b/src/plugins/data/common/field_formats/field_format.ts @@ -95,6 +95,12 @@ export abstract class FieldFormat { */ public type: any = this.constructor; + /** + * @property {boolean} - allow numeric aggregation + * @private + */ + allowsNumericalAggregations?: boolean; + protected readonly _params: any; protected getConfig: FieldFormatsGetConfigFn | undefined; diff --git a/src/plugins/vis_builder/README.md b/src/plugins/vis_builder/README.md index 88b5afbda1f4..4bbf82d9dc87 100755 --- a/src/plugins/vis_builder/README.md +++ b/src/plugins/vis_builder/README.md @@ -31,6 +31,6 @@ Outline: **Notes:** -- Currently only the metric viz is defined, so schema properties that other vis types might need may be missing and require further setup. +- Currently only the metric and table viz are defined, so schema properties that other vis types might need may be missing and require further setup. - `to_expression` has not yet been abstracted into a common utility for different visualizations. Adding more visualization types should make it easier to identify which parts of expression creation are common, and which are visualization-specific. diff --git a/src/plugins/vis_builder/public/application/utils/use/use_saved_vis_builder_vis.ts b/src/plugins/vis_builder/public/application/utils/use/use_saved_vis_builder_vis.ts index d7840b92f8ad..cfe252fafefd 100644 --- a/src/plugins/vis_builder/public/application/utils/use/use_saved_vis_builder_vis.ts +++ b/src/plugins/vis_builder/public/application/utils/use/use_saved_vis_builder_vis.ts @@ -14,6 +14,7 @@ import { import { EDIT_PATH, PLUGIN_ID } from '../../../../common'; import { VisBuilderServices } from '../../../types'; import { MetricOptionsDefaults } from '../../../visualizations/metric/metric_viz_type'; +import { TableOptionsDefaults } from '../../../visualizations/table/table_viz_type'; import { getCreateBreadcrumbs, getEditBreadcrumbs } from '../breadcrumbs'; import { getSavedVisBuilderVis } from '../get_saved_vis_builder_vis'; import { @@ -81,7 +82,7 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined } } - dispatch(setStyleState(styleState)); + dispatch(setStyleState(styleState)); dispatch(setVisualizationState(visualizationState)); } diff --git a/src/plugins/vis_builder/public/visualizations/index.ts b/src/plugins/vis_builder/public/visualizations/index.ts index 6787c28a6ff8..c867e570143e 100644 --- a/src/plugins/vis_builder/public/visualizations/index.ts +++ b/src/plugins/vis_builder/public/visualizations/index.ts @@ -5,6 +5,7 @@ import type { TypeServiceSetup } from '../services/type_service'; import { createMetricConfig } from './metric'; +import { createTableConfig } from './table'; import { createHistogramConfig, createLineConfig, createAreaConfig } from './vislib'; export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup) { @@ -13,6 +14,7 @@ export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup) { createLineConfig, createAreaConfig, createMetricConfig, + createTableConfig, ]; visualizationTypes.forEach((createTypeConfig) => { diff --git a/src/plugins/vis_builder/public/visualizations/table/components/table_viz_options.tsx b/src/plugins/vis_builder/public/visualizations/table/components/table_viz_options.tsx new file mode 100644 index 000000000000..22c91d654a9b --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/table/components/table_viz_options.tsx @@ -0,0 +1,227 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get } from 'lodash'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import produce from 'immer'; +import { Draft } from 'immer'; +import { EuiIconTip } from '@elastic/eui'; +import { search } from '../../../../../data/public'; +import { NumberInputOption, SwitchOption, SelectOption } from '../../../../../charts/public'; +import { + useTypedDispatch, + useTypedSelector, + setStyleState, +} from '../../../application/utils/state_management'; +import { useAggs } from '../../../../public/application/utils/use'; +import { TableOptionsDefaults } from '../table_viz_type'; +import { Option } from '../../../application/app'; + +export enum AggTypes { + SUM = 'sum', + AVG = 'avg', + MIN = 'min', + MAX = 'max', + COUNT = 'count', +} + +const { tabifyGetColumns } = search; + +const totalAggregations = [ + { + value: AggTypes.SUM, + text: i18n.translate('visTypeTableNew.totalAggregations.sumText', { + defaultMessage: 'Sum', + }), + }, + { + value: AggTypes.AVG, + text: i18n.translate('visTypeTableNew.totalAggregations.averageText', { + defaultMessage: 'Average', + }), + }, + { + value: AggTypes.MIN, + text: i18n.translate('visTypeTableNewNew.totalAggregations.minText', { + defaultMessage: 'Min', + }), + }, + { + value: AggTypes.MAX, + text: i18n.translate('visTypeTableNewNew.totalAggregations.maxText', { + defaultMessage: 'Max', + }), + }, + { + value: AggTypes.COUNT, + text: i18n.translate('visTypeTableNewNew.totalAggregations.countText', { + defaultMessage: 'Count', + }), + }, +]; + +function TableVizOptions() { + const styleState = useTypedSelector((state) => state.style) as TableOptionsDefaults; + const { aggConfigs } = useAggs(); + const dispatch = useTypedDispatch(); + + const setOption = useCallback( + (callback: (draft: Draft) => void) => { + const newState = produce(styleState, callback); + dispatch(setStyleState(newState)); + }, + [dispatch, styleState] + ); + + const percentageColumns = useMemo(() => { + const defaultPercentageColText = { + value: '', + text: i18n.translate('visTypeTableNew.params.defaultPercentageCol', { + defaultMessage: 'Don’t show', + }), + }; + return aggConfigs + ? [ + defaultPercentageColText, + ...tabifyGetColumns(aggConfigs.getResponseAggs(), true) + .filter((col) => get(col.aggConfig.toSerializedFieldFormat(), 'id') === 'number') + .map(({ name }) => ({ value: name, text: name })), + ] + : [defaultPercentageColText]; + }, [aggConfigs]); + + useEffect(() => { + if ( + !percentageColumns.find(({ value }) => value === styleState.percentageCol) && + percentageColumns[0] && + percentageColumns[0].value !== styleState.percentageCol + ) { + setOption((draft) => { + draft.percentageCol = percentageColumns[0].value; + }); + } + }, [percentageColumns, styleState.percentageCol, setOption]); + + const isPerPageValid = styleState.perPage === '' || styleState.perPage > 0; + + return ( + <> + + + ); +} + +export { TableVizOptions }; diff --git a/src/plugins/vis_builder/public/visualizations/table/index.ts b/src/plugins/vis_builder/public/visualizations/table/index.ts new file mode 100644 index 000000000000..51fd19d291e7 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/table/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { createTableConfig } from './table_viz_type'; diff --git a/src/plugins/vis_builder/public/visualizations/table/table_viz_type.ts b/src/plugins/vis_builder/public/visualizations/table/table_viz_type.ts new file mode 100644 index 000000000000..3f52d265c92d --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/table/table_viz_type.ts @@ -0,0 +1,109 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Schemas } from '../../../../vis_default_editor/public'; +import { AggGroupNames } from '../../../../data/public'; +import { TableVizOptions } from './components/table_viz_options'; +import { VisualizationTypeOptions } from '../../services/type_service'; +import { toExpression } from './to_expression'; + +export enum AggTypes { + SUM = 'sum', + AVG = 'avg', + MIN = 'min', + MAX = 'max', + COUNT = 'count', +} + +export interface TableOptionsDefaults { + perPage: number | ''; + showPartialRows: boolean; + showMetricsAtAllLevels: boolean; + showTotal: boolean; + totalFunc: AggTypes; + percentageCol: string; +} + +export const createTableConfig = (): VisualizationTypeOptions => ({ + name: 'table', + title: 'Table', + icon: 'visTable', + description: 'Display table visualizations', + toExpression, + ui: { + containerConfig: { + data: { + schemas: new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeTableNewNew.tableVisEditorConfig.schemas.metricTitle', { + defaultMessage: 'Metric', + }), + min: 1, + aggFilter: ['!geo_centroid', '!geo_bounds'], + aggSettings: { + top_hits: { + allowStrings: true, + }, + }, + defaults: { + aggTypes: ['avg', 'cardinality'], + }, + }, + { + group: AggGroupNames.Buckets, + name: 'bucket', + title: i18n.translate('visTypeTableNewNew.tableVisEditorConfig.schemas.bucketTitle', { + defaultMessage: 'Split rows', + }), + aggFilter: ['!filter'], + defaults: { + aggTypes: ['terms'], + }, + }, + { + group: AggGroupNames.Buckets, + name: 'split_row', + title: i18n.translate('visTypeTableNewNew.tableVisEditorConfig.schemas.splitTitle', { + defaultMessage: 'Split table in rows', + }), + min: 0, + max: 1, + aggFilter: ['!filter'], + defaults: { + aggTypes: ['terms'], + }, + }, + { + group: AggGroupNames.Buckets, + name: 'split_column', + title: i18n.translate('visTypeTableNewNew.tableVisEditorConfig.schemas.splitTitle', { + defaultMessage: 'Split table in columns', + }), + min: 0, + max: 1, + aggFilter: ['!filter'], + defaults: { + aggTypes: ['terms'], + }, + }, + ]), + }, + style: { + defaults: { + perPage: 10, + showPartialRows: false, + showMetricsAtAllLevels: false, + showTotal: false, + totalFunc: AggTypes.SUM, + percentageCol: '', + }, + render: TableVizOptions, + }, + }, + }, +}); diff --git a/src/plugins/vis_builder/public/visualizations/table/to_expression.ts b/src/plugins/vis_builder/public/visualizations/table/to_expression.ts new file mode 100644 index 000000000000..9dbc18d6b9e8 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/table/to_expression.ts @@ -0,0 +1,165 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SchemaConfig } from '../../../../visualizations/public'; +import { TableVisExpressionFunctionDefinition } from '../../../../vis_type_table_new/public'; +import { AggConfigs, IAggConfig } from '../../../../data/common'; +import { buildExpression, buildExpressionFunction } from '../../../../expressions/public'; +import { RenderState } from '../../application/utils/state_management'; +import { TableOptionsDefaults } from './table_viz_type'; +import { getAggExpressionFunctions } from '../common/expression_helpers'; +import { OpenSearchaggsExpressionFunctionDefinition } from '../../../../data/public'; + +// TODO: Update to the common getShemas from src/plugins/visualizations/public/legacy/build_pipeline.ts +// And move to a common location accessible by all the visualizations +const getVisSchemas = ( + aggConfigs: AggConfigs, + showPartialRows: boolean, + showMetricsAtAllLevels: boolean +): any => { + const createSchemaConfig = (accessor: number, agg: IAggConfig): SchemaConfig => { + const hasSubAgg = [ + 'derivative', + 'moving_avg', + 'serial_diff', + 'cumulative_sum', + 'sum_bucket', + 'avg_bucket', + 'min_bucket', + 'max_bucket', + ].includes(agg.type.name); + + const formatAgg = hasSubAgg + ? agg.params.customMetric || agg.aggConfigs.getRequestAggById(agg.params.metricAgg) + : agg; + + const params = {}; + + const label = agg.makeLabel && agg.makeLabel(); + + return { + accessor, + format: formatAgg.toSerializedFieldFormat(), + params, + label, + aggType: agg.type.name, + }; + }; + + let cnt = 0; + const schemas: any = { + metric: [], + }; + + if (!aggConfigs) { + return schemas; + } + + const responseAggs = aggConfigs.getResponseAggs().filter((agg: IAggConfig) => agg.enabled); + const metrics = responseAggs.filter((agg: IAggConfig) => agg.type.type === 'metrics'); + const isHierarchical = showMetricsAtAllLevels; + responseAggs.forEach((agg) => { + let skipMetrics = false; + const schemaName = agg.schema; + + if (!schemaName) { + cnt++; + return; + } + + if (schemaName === 'split_row' || schemaName === 'split_column') { + skipMetrics = responseAggs.length - metrics.length > 1; + } + + if (!schemas[schemaName]) { + schemas[schemaName] = []; + } + + if (!isHierarchical || agg.type.type !== 'metrics') { + schemas[schemaName]!.push(createSchemaConfig(cnt++, agg)); + } + if (isHierarchical && (agg.type.type !== 'metrics' || metrics.length === responseAggs.length)) { + metrics.forEach((metric: any) => { + const schemaConfig = createSchemaConfig(cnt++, metric); + if (!skipMetrics) { + schemas.metric.push(schemaConfig); + } + }); + } + }); + + return schemas; +}; + +export interface TableRootState extends RenderState { + style: TableOptionsDefaults; +} + +export const toExpression = async ({ style: styleState, visualization }: TableRootState) => { + const { aggConfigs, indexPattern, expressionFns } = await getAggExpressionFunctions( + visualization + ); + const { id: indexId = '' } = indexPattern; + let [opensearchaggs] = expressionFns; + + const { + perPage, + showPartialRows, + showMetricsAtAllLevels, + showTotal, + totalFunc, + percentageCol, + } = styleState; + + const schemas = getVisSchemas(aggConfigs, showPartialRows, showMetricsAtAllLevels); + + const metrics = + schemas.bucket && showPartialRows && !showMetricsAtAllLevels + ? schemas.metric.slice(-1 * (schemas.metric.length / schemas.bucket.length)) + : schemas.metric; + + const tableData = { + metrics, + buckets: schemas.bucket || [], + splitRow: schemas.split_row, + splitColumn: schemas.split_column, + }; + + const tableConfig = { + perPage, + percentageCol, + showPartialRows, + showMetricsAtAllLevels, + showTotal, + totalFunc, + }; + + const visConfig = { + ...tableConfig, + ...tableData, + }; + + // Update buildExpressionFunction to correctly handle optional arguments + const tableVis = buildExpressionFunction( + 'opensearch_dashboards_table_new', + { + visConfig: JSON.stringify(visConfig), + } + ); + + opensearchaggs = buildExpressionFunction( + 'opensearchaggs', + { + index: indexId, + metricsAtAllLevels: showMetricsAtAllLevels, + partialRows: showPartialRows, + aggConfigs: JSON.stringify(aggConfigs.aggs), + includeFormatHints: false, + } + ); + + const expressionFnsTableVis = [expressionFns[0], opensearchaggs]; + return buildExpression([...expressionFnsTableVis, tableVis]).toString(); +}; diff --git a/src/plugins/vis_type_table_new/README.md b/src/plugins/vis_type_table_new/README.md new file mode 100644 index 000000000000..06299ed963a2 --- /dev/null +++ b/src/plugins/vis_type_table_new/README.md @@ -0,0 +1 @@ +Contains the data table visualization, that allows presenting data using a Datagrid component. diff --git a/src/plugins/vis_type_table_new/config.ts b/src/plugins/vis_type_table_new/config.ts new file mode 100644 index 000000000000..b7869c80f11a --- /dev/null +++ b/src/plugins/vis_type_table_new/config.ts @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema, TypeOf } from '@osd/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), +}); + +export type ConfigSchema = TypeOf; diff --git a/src/plugins/vis_type_table_new/opensearch_dashboards.json b/src/plugins/vis_type_table_new/opensearch_dashboards.json new file mode 100644 index 000000000000..a268c86195e2 --- /dev/null +++ b/src/plugins/vis_type_table_new/opensearch_dashboards.json @@ -0,0 +1,16 @@ +{ + "id": "visTypeTableNew", + "version": "opensearchDashboards", + "server": true, + "ui": true, + "requiredPlugins": [ + "expressions", + "visualizations", + "data" + ], + "requiredBundles": [ + "opensearchDashboardsUtils", + "opensearchDashboardsReact", + "share" + ] +} diff --git a/src/plugins/vis_type_table_new/public/components/table_vis_app.scss b/src/plugins/vis_type_table_new/public/components/table_vis_app.scss new file mode 100644 index 000000000000..666df3614c17 --- /dev/null +++ b/src/plugins/vis_type_table_new/public/components/table_vis_app.scss @@ -0,0 +1,19 @@ +.visTable { + flex-direction: column; + flex-grow: 1 0 0; +} + +.visTable__group { + padding: $euiSizeS; + margin-bottom: $euiSizeL; + + > h3 { + text-align: center; + } +} + +.visTable__groupInColumns { + display: flex; + flex-direction: row; + align-items: flex-start; +} diff --git a/src/plugins/vis_type_table_new/public/components/table_vis_app.tsx b/src/plugins/vis_type_table_new/public/components/table_vis_app.tsx new file mode 100644 index 000000000000..4b23d707133c --- /dev/null +++ b/src/plugins/vis_type_table_new/public/components/table_vis_app.tsx @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './table_vis_app.scss'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { I18nProvider } from '@osd/i18n/react'; +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; + +import { TableContext } from '../table_vis_response_handler'; +import { TableVisConfig, SortColumn, ColumnWidth, TableUiState } from '../types'; +import { TableVisComponent } from './table_vis_component'; +import { TableVisComponentGroup } from './table_vis_component_group'; + +interface TableVisAppProps { + visData: TableContext; + visConfig: TableVisConfig; + handlers: IInterpreterRenderHandlers; +} + +export const TableVisApp = ({ + services, + visData: { table, tableGroups, direction }, + visConfig, + handlers, +}: TableVisAppProps & { services: CoreStart }) => { + useEffect(() => { + handlers.done(); + }, [handlers]); + + const className = classNames('visTable', { + // eslint-disable-next-line @typescript-eslint/naming-convention + visTable__groupInColumns: direction === 'column', + }); + + // TODO: remove duplicate sort and width state + // Issue: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/2704#issuecomment-1299380818 + const [sort, setSort] = useState( + handlers.uiState.get('vis.sortColumn') || { colIndex: null, direction: null } + ); + const [width, setWidth] = useState(handlers.uiState.get('vis.sortColumn') || []); + + const tableUiState: TableUiState = { sort, setSort, width, setWidth }; + + return ( + + +
+ {table ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { TableVisApp as default }; diff --git a/src/plugins/vis_type_table_new/public/components/table_vis_component.tsx b/src/plugins/vis_type_table_new/public/components/table_vis_component.tsx new file mode 100644 index 000000000000..2f788eb3b41d --- /dev/null +++ b/src/plugins/vis_type_table_new/public/components/table_vis_component.tsx @@ -0,0 +1,151 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo } from 'react'; +import { orderBy } from 'lodash'; +import { EuiDataGridProps, EuiDataGrid, EuiDataGridSorting, EuiTitle } from '@elastic/eui'; + +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { Table } from '../table_vis_response_handler'; +import { TableVisConfig, ColumnWidth, SortColumn, TableUiState } from '../types'; +import { getDataGridColumns } from './table_vis_grid_columns'; +import { usePagination } from '../utils'; +import { convertToFormattedData } from '../utils/convert_to_formatted_data'; +import { TableVisControl } from './table_vis_control'; + +interface TableVisComponentProps { + title?: string; + table: Table; + visConfig: TableVisConfig; + event: IInterpreterRenderHandlers['event']; + uiState: TableUiState; +} + +export const TableVisComponent = ({ + title, + table, + visConfig, + event, + uiState, +}: TableVisComponentProps) => { + const { formattedRows: rows, formattedColumns: columns } = convertToFormattedData( + table, + visConfig + ); + + const pagination = usePagination(visConfig, rows.length); + + const sortedRows = useMemo(() => { + return uiState.sort && uiState.sort.colIndex !== null && uiState.sort.direction + ? orderBy(rows, columns[uiState.sort.colIndex]?.id, uiState.sort.direction) + : rows; + }, [columns, rows, uiState]); + + const renderCellValue = useMemo(() => { + return (({ rowIndex, columnId }) => { + const rawContent = sortedRows[rowIndex][columnId]; + const colIndex = columns.findIndex((col) => col.id === columnId); + const column = columns[colIndex]; + // use formatter to format raw content + // this can format date and percentage data + const formattedContent = column.formatter.convert(rawContent, 'text'); + return sortedRows.hasOwnProperty(rowIndex) ? formattedContent || null : null; + }) as EuiDataGridProps['renderCellValue']; + }, [sortedRows, columns]); + + const dataGridColumns = getDataGridColumns(sortedRows, columns, table, event, uiState.width); + + const sortedColumns = useMemo(() => { + return uiState.sort && uiState.sort.colIndex !== null && uiState.sort.direction + ? [{ id: dataGridColumns[uiState.sort.colIndex]?.id, direction: uiState.sort.direction }] + : []; + }, [dataGridColumns, uiState]); + + const onSort = useCallback( + (sortingCols: EuiDataGridSorting['columns'] | []) => { + const nextSortValue = sortingCols[sortingCols.length - 1]; + const nextSort: SortColumn = + sortingCols.length > 0 + ? { + colIndex: dataGridColumns.findIndex((col) => col.id === nextSortValue?.id), + direction: nextSortValue.direction, + } + : { + colIndex: null, + direction: null, + }; + uiState.setSort(nextSort); + return nextSort; + }, + [dataGridColumns, uiState] + ); + + const onColumnResize: EuiDataGridProps['onColumnResize'] = useCallback( + ({ columnId, width }) => { + const curWidth: ColumnWidth[] = uiState.width; + const nextWidth = [...curWidth]; + const nextColIndex = columns.findIndex((col) => col.id === columnId); + const curColIndex = curWidth.findIndex((col) => col.colIndex === nextColIndex); + const nextColWidth = { colIndex: nextColIndex, width }; + + // if updated column index is not found, then add it to nextWidth + // else reset it in nextWidth + if (curColIndex < 0) nextWidth.push(nextColWidth); + else nextWidth[curColIndex] = nextColWidth; + + // update uiState.width + uiState.setWidth(nextWidth); + }, + [columns, uiState] + ); + + const ariaLabel = title || visConfig.title || 'tableVis'; + + const footerCellValue = visConfig.showTotal + ? // @ts-expect-error + ({ columnId }) => { + const colIndex = columns.findIndex((col) => col.id === columnId); + return columns[colIndex]?.formattedTotal || null; + } + : undefined; + + return ( + <> + {title && ( + +

{title}

+
+ )} + id), + setVisibleColumns: () => {}, + }} + rowCount={rows.length} + renderCellValue={renderCellValue} + sorting={{ columns: sortedColumns, onSort }} + onColumnResize={onColumnResize} + pagination={pagination} + gridStyle={{ + border: 'horizontal', + header: 'underline', + }} + minSizeForControls={1} + renderFooterCellValue={footerCellValue} + toolbarVisibility={{ + showColumnSelector: false, + showSortSelector: false, + showFullScreenSelector: false, + showStyleSelector: false, + additionalControls: ( + + ), + }} + /> + + ); +}; diff --git a/src/plugins/vis_type_table_new/public/components/table_vis_component_group.tsx b/src/plugins/vis_type_table_new/public/components/table_vis_component_group.tsx new file mode 100644 index 000000000000..633b9d2230bd --- /dev/null +++ b/src/plugins/vis_type_table_new/public/components/table_vis_component_group.tsx @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { memo } from 'react'; + +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { TableGroup } from '../table_vis_response_handler'; +import { TableVisConfig, TableUiState } from '../types'; +import { TableVisComponent } from './table_vis_component'; + +interface TableVisGroupComponentProps { + tableGroups: TableGroup[]; + visConfig: TableVisConfig; + event: IInterpreterRenderHandlers['event']; + uiState: TableUiState; +} + +export const TableVisComponentGroup = memo( + ({ tableGroups, visConfig, event, uiState }: TableVisGroupComponentProps) => { + return ( + <> + {tableGroups.map(({ tables, title }) => ( +
+ +
+ ))} + + ); + } +); diff --git a/src/plugins/vis_type_table_new/public/components/table_vis_control.tsx b/src/plugins/vis_type_table_new/public/components/table_vis_control.tsx new file mode 100644 index 000000000000..26b51c9cc85b --- /dev/null +++ b/src/plugins/vis_type_table_new/public/components/table_vis_control.tsx @@ -0,0 +1,55 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { EuiPopover, EuiButtonEmpty, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { OpenSearchDashboardsDatatableRow } from 'src/plugins/expressions'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { exportAsCsv } from '../utils/convert_to_csv_data'; +import { FormattedColumn } from '../types'; +import { useOpenSearchDashboards } from '../../../../../src/plugins/opensearch_dashboards_react/public'; + +interface TableVisControlProps { + filename?: string; + rows: OpenSearchDashboardsDatatableRow[]; + columns: FormattedColumn[]; +} + +export const TableVisControl = (props: TableVisControlProps) => { + const { + services: { uiSettings }, + } = useOpenSearchDashboards(); + const [isPopoverOpen, setPopover] = useState(false); + + return ( + setPopover((open) => !open)} /> + } + isOpen={isPopoverOpen} + closePopover={() => setPopover(false)} + panelPaddingSize="none" + > + exportAsCsv(false, { ...props, uiSettings })} + > + Raw + , + exportAsCsv(true, { ...props, uiSettings })} + > + Formatted + , + ]} + /> + + ); +}; diff --git a/src/plugins/vis_type_table_new/public/components/table_vis_grid_columns.tsx b/src/plugins/vis_type_table_new/public/components/table_vis_grid_columns.tsx new file mode 100644 index 000000000000..036cac284f68 --- /dev/null +++ b/src/plugins/vis_type_table_new/public/components/table_vis_grid_columns.tsx @@ -0,0 +1,148 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiDataGridColumn, EuiDataGridColumnCellActionProps } from '@elastic/eui'; +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { OpenSearchDashboardsDatatableRow } from 'src/plugins/expressions'; +import { Table } from '../table_vis_response_handler'; +import { ColumnWidth, FormattedColumn } from '../types'; + +export const getDataGridColumns = ( + rows: OpenSearchDashboardsDatatableRow[], + cols: FormattedColumn[], + table: Table, + event: IInterpreterRenderHandlers['event'], + columnsWidth: ColumnWidth[] +) => { + const filterBucket = (rowIndex: number, columnIndex: number, negate: boolean) => { + const foramttedColumnId = cols[columnIndex].id; + const rawColumnIndex = table.columns.findIndex((col) => col.id === foramttedColumnId); + event({ + name: 'filterBucket', + data: { + data: [ + { + table: { + columns: table.columns, + rows, + }, + row: rowIndex, + column: rawColumnIndex, + }, + ], + negate, + }, + }); + }; + + return cols.map((col, colIndex) => { + const cellActions = col.filterable + ? [ + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const filterValue = rows[rowIndex][columnId]; + const filterContent = col.formatter?.convert(filterValue); + + const filterForValueText = i18n.translate( + 'visTypeTableNew.tableVisFilter.filterForValue', + { + defaultMessage: 'Filter for value', + } + ); + const filterForValueLabel = i18n.translate( + 'visTypeTableNew.tableVisFilter.filterForValueLabel', + { + defaultMessage: 'Filter for value: {filterContent}', + values: { + filterContent, + }, + } + ); + + return ( + filterValue != null && ( + { + filterBucket(rowIndex, colIndex, false); + closePopover(); + }} + iconType="plusInCircle" + aria-label={filterForValueLabel} + data-test-subj="filterForValue" + > + {filterForValueText} + + ) + ); + }, + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const filterValue = rows[rowIndex][columnId]; + const filterContent = col.formatter?.convert(filterValue); + + const filterOutValueText = i18n.translate( + 'visTypeTableNew.tableVisFilter.filterOutValue', + { + defaultMessage: 'Filter out value', + } + ); + const filterOutValueLabel = i18n.translate( + 'visTypeTableNew.tableVisFilter.filterOutValueLabel', + { + defaultMessage: 'Filter out value: {filterContent}', + values: { + filterContent, + }, + } + ); + + return ( + filterValue != null && ( + { + filterBucket(rowIndex, colIndex, true); + closePopover(); + }} + iconType="minusInCircle" + aria-label={filterOutValueLabel} + data-test-subj="filterOutValue" + > + {filterOutValueText} + + ) + ); + }, + ] + : undefined; + + const initialWidth = columnsWidth.find((c) => c.colIndex === colIndex); + + const dataGridColumn: EuiDataGridColumn = { + id: col.id, + display: col.title, + displayAsText: col.title, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: { + label: i18n.translate('visTypeTableNew.tableVisSort.ascSortLabel', { + defaultMessage: 'Sort asc', + }), + }, + showSortDesc: { + label: i18n.translate('visTypeTableNew.tableVisSort.descSortLabel', { + defaultMessage: 'Sort desc', + }), + }, + }, + cellActions, + }; + if (initialWidth) { + dataGridColumn.initialWidth = initialWidth.width; + } + return dataGridColumn; + }); +}; diff --git a/src/plugins/vis_type_table_new/public/index.ts b/src/plugins/vis_type_table_new/public/index.ts new file mode 100644 index 000000000000..d9b086e2cb6b --- /dev/null +++ b/src/plugins/vis_type_table_new/public/index.ts @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializerContext } from 'opensearch-dashboards/public'; +import { TableVisPlugin as Plugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new Plugin(initializerContext); +} +/* Public Types */ +export { TableVisExpressionFunctionDefinition } from './table_vis_fn'; diff --git a/src/plugins/vis_type_table_new/public/plugin.ts b/src/plugins/vis_type_table_new/public/plugin.ts new file mode 100644 index 000000000000..a650db0e7df7 --- /dev/null +++ b/src/plugins/vis_type_table_new/public/plugin.ts @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, +} from 'opensearch-dashboards/public'; +import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; +import { VisualizationsSetup } from '../../visualizations/public'; + +import { createTableVisFn } from './table_vis_fn'; +import { DataPublicPluginStart } from '../../data/public'; +import { setFormatService } from './services'; +import { getTableVisRenderer } from './table_vis_renderer'; + +/** @internal */ +export interface TableVisPluginSetupDependencies { + expressions: ReturnType; + visualizations: VisualizationsSetup; +} + +/** @internal */ +export interface TableVisPluginStartDependencies { + data: DataPublicPluginStart; +} + +const setupTableVis = async (core: CoreSetup, { expressions }: TableVisPluginSetupDependencies) => { + const [coreStart] = await core.getStartServices(); + expressions.registerFunction(createTableVisFn); + expressions.registerRenderer(getTableVisRenderer(coreStart)); +}; + +/** @internal */ +export class TableVisPlugin implements Plugin { + initializerContext: PluginInitializerContext; + + constructor(initializerContext: PluginInitializerContext) { + this.initializerContext = initializerContext; + } + + public async setup(core: CoreSetup, dependencies: TableVisPluginSetupDependencies) { + setupTableVis(core, dependencies); + } + + public start(core: CoreStart, { data }: TableVisPluginStartDependencies) { + setFormatService(data.fieldFormats); + } +} diff --git a/src/plugins/vis_type_table_new/public/services.ts b/src/plugins/vis_type_table_new/public/services.ts new file mode 100644 index 000000000000..65c9b4ff5fc5 --- /dev/null +++ b/src/plugins/vis_type_table_new/public/services.ts @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createGetterSetter } from '../../opensearch_dashboards_utils/public'; +import { DataPublicPluginStart } from '../../data/public'; + +export const [getFormatService, setFormatService] = createGetterSetter< + DataPublicPluginStart['fieldFormats'] +>('table data.fieldFormats'); diff --git a/src/plugins/vis_type_table_new/public/table_vis_fn.ts b/src/plugins/vis_type_table_new/public/table_vis_fn.ts new file mode 100644 index 000000000000..ec9eafc344af --- /dev/null +++ b/src/plugins/vis_type_table_new/public/table_vis_fn.ts @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { tableVisResponseHandler, TableContext } from './table_vis_response_handler'; +import { + ExpressionFunctionDefinition, + OpenSearchDashboardsDatatable, + Render, +} from '../../expressions/public'; +import { TableVisConfig } from './types'; + +export type Input = OpenSearchDashboardsDatatable; + +interface Arguments { + visConfig: string | null; +} + +export interface TableVisRenderValue { + visData: TableContext; + visType: 'table'; + visConfig: TableVisConfig; +} + +export type TableVisExpressionFunctionDefinition = ExpressionFunctionDefinition< + 'opensearch_dashboards_table_new', + Input, + Arguments, + Render +>; + +export const createTableVisFn = (): TableVisExpressionFunctionDefinition => ({ + name: 'opensearch_dashboards_table_new', + type: 'render', + inputTypes: ['opensearch_dashboards_datatable'], + help: i18n.translate('visTypeTableNew.function.help', { + defaultMessage: 'Table visualization', + }), + args: { + visConfig: { + types: ['string', 'null'], + default: '"{}"', + help: '', + }, + }, + fn(input, args) { + const visConfig = args.visConfig && JSON.parse(args.visConfig); + const convertedData = tableVisResponseHandler(input, visConfig); + + return { + type: 'render', + as: 'table_vis', + value: { + visData: convertedData, + visType: 'table', + visConfig, + }, + params: { + listenOnChange: true, + }, + }; + }, +}); diff --git a/src/plugins/vis_type_table_new/public/table_vis_renderer.tsx b/src/plugins/vis_type_table_new/public/table_vis_renderer.tsx new file mode 100644 index 000000000000..8e467112528d --- /dev/null +++ b/src/plugins/vis_type_table_new/public/table_vis_renderer.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; + +import { CoreStart } from 'opensearch-dashboards/public'; +import { VisualizationContainer } from '../../visualizations/public'; +import { ExpressionRenderDefinition } from '../../expressions/common/expression_renderers'; +import { TableVisRenderValue } from './table_vis_fn'; +import { TableVisApp } from './components/table_vis_app'; + +export const getTableVisRenderer: ( + core: CoreStart +) => ExpressionRenderDefinition = (core) => ({ + name: 'table_vis', + displayName: 'table visualization', + reuseDomNode: true, + render: async (domNode, { visData, visConfig }, handlers) => { + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + const showNoResult = visData.table + ? visData.table.rows.length === 0 + : visData.tableGroups?.length === 0; + render( + + + , + domNode + ); + }, +}); diff --git a/src/plugins/vis_type_table_new/public/table_vis_response_handler.ts b/src/plugins/vis_type_table_new/public/table_vis_response_handler.ts new file mode 100644 index 000000000000..b1d41edfff8b --- /dev/null +++ b/src/plugins/vis_type_table_new/public/table_vis_response_handler.ts @@ -0,0 +1,112 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getFormatService } from './services'; +import { OpenSearchDashboardsDatatable } from '../../expressions/public'; +import { TableVisConfig } from './types'; + +export interface Table { + columns: OpenSearchDashboardsDatatable['columns']; + rows: OpenSearchDashboardsDatatable['rows']; +} + +export interface TableGroup { + table: OpenSearchDashboardsDatatable; + tables: Table[]; + title: string; + name: string; + key: any; + column: number; + row: number; +} + +export interface TableContext { + table?: Table; + tableGroups: TableGroup[]; + direction?: 'row' | 'column'; +} + +export function tableVisResponseHandler( + input: OpenSearchDashboardsDatatable, + config: TableVisConfig +): TableContext { + let table: Table | undefined; + const tableGroups: TableGroup[] = []; + let direction: TableContext['direction']; + + const split = config.splitColumn || config.splitRow; + + if (split) { + direction = config.splitRow ? 'row' : 'column'; + const splitColumnIndex = split[0].accessor; + const splitColumnFormatter = getFormatService().deserialize(split[0].format); + const splitColumn = input.columns[splitColumnIndex]; + const splitMap: { [key: string]: number } = {}; + let splitIndex = 0; + + input.rows.forEach((row, rowIndex) => { + const splitValue: any = row[splitColumn.id]; + + if (!splitMap.hasOwnProperty(splitValue as any)) { + (splitMap as any)[splitValue] = splitIndex++; + const tableGroup: TableGroup = { + title: `${splitColumnFormatter.convert(splitValue)}: ${splitColumn.name}`, + name: splitColumn.name, + key: splitValue, + column: splitColumnIndex, + row: rowIndex, + table: input, + tables: [], + }; + + tableGroup.tables.push({ + columns: input.columns, + rows: [], + }); + + tableGroups.push(tableGroup); + } + + const tableIndex = (splitMap as any)[splitValue]; + (tableGroups[tableIndex] as any).tables[0].rows.push(row); + }); + } else { + table = { + columns: input.columns, + rows: input.rows, + }; + } + + return { + table, + tableGroups, + direction, + }; +} diff --git a/src/plugins/vis_type_table_new/public/types.ts b/src/plugins/vis_type_table_new/public/types.ts new file mode 100644 index 000000000000..f77abe78a1fa --- /dev/null +++ b/src/plugins/vis_type_table_new/public/types.ts @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SchemaConfig } from 'src/plugins/visualizations/public'; +import { IFieldFormat } from 'src/plugins/data/public'; + +export enum AggTypes { + SUM = 'sum', + AVG = 'avg', + MIN = 'min', + MAX = 'max', + COUNT = 'count', +} + +export interface TableVisConfig extends TableVisParams { + title: string; + metrics: SchemaConfig[]; + buckets: SchemaConfig[]; + splitRow?: SchemaConfig[]; + splitColumn?: SchemaConfig[]; +} + +export interface TableVisParams { + perPage: number | ''; + showPartialRows: boolean; + showMetricsAtAllLevels: boolean; + showTotal: boolean; + totalFunc: AggTypes; + percentageCol: string; +} + +export interface FormattedColumn { + id: string; + title: string; + formatter: IFieldFormat; + filterable: boolean; + formattedTotal?: string | number; + sumTotal?: number; + total?: number; +} + +export interface ColumnWidth { + colIndex: number; + width: number; +} + +export interface SortColumn { + colIndex: number | null; + direction: 'asc' | 'desc' | null; +} + +export interface TableUiState { + sort: SortColumn; + setSort: (sort: SortColumn) => void; + width: ColumnWidth[]; + setWidth: (columnsWidth: ColumnWidth[]) => void; +} diff --git a/src/plugins/vis_type_table_new/public/utils/convert_to_csv_data.ts b/src/plugins/vis_type_table_new/public/utils/convert_to_csv_data.ts new file mode 100644 index 000000000000..2c37df1aa3d5 --- /dev/null +++ b/src/plugins/vis_type_table_new/public/utils/convert_to_csv_data.ts @@ -0,0 +1,85 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isObject } from 'lodash'; +// @ts-ignore +import { saveAs } from '@elastic/filesaver'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../../../share/public'; +import { OpenSearchDashboardsDatatable } from '../../../expressions/public'; +import { FormattedColumn } from '../types'; + +const nonAlphaNumRE = /[^a-zA-Z0-9]/; +const allDoubleQuoteRE = /"/g; + +interface CSVDataProps { + filename?: string; + rows: OpenSearchDashboardsDatatable['rows']; + columns: FormattedColumn[]; + uiSettings: CoreStart['uiSettings']; +} + +const toCsv = function (formatted: boolean, { rows, columns, uiSettings }: CSVDataProps) { + const separator = uiSettings.get(CSV_SEPARATOR_SETTING); + const quoteValues = uiSettings.get(CSV_QUOTE_VALUES_SETTING); + + function escape(val: any) { + if (!formatted && isObject(val)) val = val.valueOf(); + val = String(val); + if (quoteValues && nonAlphaNumRE.test(val)) { + val = '"' + val.replace(allDoubleQuoteRE, '""') + '"'; + } + return val; + } + + let csvRows: string[][] = []; + for (const row of rows) { + const rowArray = []; + for (const col of columns) { + const value = row[col.id]; + const formattedValue = + formatted && col.formatter ? escape(col.formatter.convert(value)) : escape(value); + rowArray.push(formattedValue); + } + csvRows = [...csvRows, rowArray]; + } + + // add the columns to the rows + csvRows.unshift(columns.map((col) => escape(col.title))); + + return csvRows.map((row) => row.join(separator) + '\r\n').join(''); +}; + +export const exportAsCsv = function (formatted: boolean, csvData: CSVDataProps) { + const csv = new Blob([toCsv(formatted, csvData)], { type: 'text/csv;charset=utf-8' }); + const type = formatted ? 'formatted' : 'raw'; + if (csvData.filename) saveAs(csv, `${csvData.filename}-${type}.csv`); + else saveAs(csv, `unsaved-${type}.csv`); +}; diff --git a/src/plugins/vis_type_table_new/public/utils/convert_to_formatted_data.ts b/src/plugins/vis_type_table_new/public/utils/convert_to_formatted_data.ts new file mode 100644 index 000000000000..3dbf7c291355 --- /dev/null +++ b/src/plugins/vis_type_table_new/public/utils/convert_to_formatted_data.ts @@ -0,0 +1,179 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@osd/i18n'; +import { chain } from 'lodash'; +import { OpenSearchDashboardsDatatableRow } from 'src/plugins/expressions'; +import { Table } from '../table_vis_response_handler'; +import { AggTypes, TableVisConfig } from '../types'; +import { getFormatService } from '../services'; +import { FormattedColumn } from '../types'; + +function insert(arr: FormattedColumn[], index: number, col: FormattedColumn) { + const newArray = [...arr]; + newArray.splice(index + 1, 0, col); + return newArray; +} + +/** + * @param columns - the formatted columns that will be displayed + * @param title - the title of the column to add to + * @param rows - the row data for the columns + * @param insertAtIndex - the index to insert the percentage column at + * @returns cols and rows for the table to render now included percentage column(s) + */ +function addPercentageCol( + columns: FormattedColumn[], + title: string, + rows: Table['rows'], + insertAtIndex: number +) { + const { id, sumTotal } = columns[insertAtIndex]; + const newId = `${id}-percents`; + const formatter = getFormatService().deserialize({ id: 'percent' }); + const i18nTitle = i18n.translate('visTypeTableNew.params.percentageTableColumnName', { + defaultMessage: '{title} percentages', + values: { title }, + }); + const newCols = insert(columns, insertAtIndex, { + title: i18nTitle, + id: newId, + formatter, + filterable: false, + }); + const newRows = rows.map((row) => ({ + [newId]: (row[id] as number) / (sumTotal as number), + ...row, + })); + + return { cols: newCols, rows: newRows }; +} + +export interface FormattedDataProps { + formattedRows: OpenSearchDashboardsDatatableRow[]; + formattedColumns: FormattedColumn[]; +} + +export const convertToFormattedData = ( + table: Table, + visConfig: TableVisConfig +): FormattedDataProps => { + const { buckets, metrics } = visConfig; + let formattedRows: OpenSearchDashboardsDatatableRow[] = table.rows; + let formattedColumns: FormattedColumn[] = table.columns + .map(function (col, i) { + const isBucket = buckets.find((bucket) => bucket.accessor === i); + const dimension = isBucket || metrics.find((metric) => metric.accessor === i); + + if (!dimension) return undefined; + + const formatter = getFormatService().deserialize(dimension.format); + + const formattedColumn: FormattedColumn = { + id: col.id, + title: col.name, + formatter, + filterable: !!isBucket, + }; + + const isDate = dimension?.format?.id === 'date' || dimension?.format?.params?.id === 'date'; + const allowsNumericalAggregations = formatter?.allowsNumericalAggregations; + + if (allowsNumericalAggregations || isDate || visConfig.totalFunc === AggTypes.COUNT) { + const sum = table.rows.reduce((prev, curr) => { + // some metrics return undefined for some of the values + // derivative is an example of this as it returns undefined in the first row + if (curr[col.id] === undefined) return prev; + return prev + (curr[col.id] as number); + }, 0); + + formattedColumn.sumTotal = sum; + switch (visConfig.totalFunc) { + case AggTypes.SUM: { + if (!isDate) { + formattedColumn.formattedTotal = formatter?.convert(sum); + formattedColumn.total = formattedColumn.sumTotal; + } + break; + } + case AggTypes.AVG: { + if (!isDate) { + const total = sum / table.rows.length; + formattedColumn.formattedTotal = formatter?.convert(total); + formattedColumn.total = total; + } + break; + } + case AggTypes.MIN: { + const total = chain(table.rows).map(col.id).min().value() as number; + formattedColumn.formattedTotal = formatter?.convert(total); + formattedColumn.total = total; + break; + } + case AggTypes.MAX: { + const total = chain(table.rows).map(col.id).max().value() as number; + formattedColumn.formattedTotal = formatter?.convert(total); + formattedColumn.total = total; + break; + } + case 'count': { + const total = table.rows.length; + formattedColumn.formattedTotal = total; + formattedColumn.total = total; + break; + } + default: + break; + } + } + + return formattedColumn; + }) + .filter((column): column is FormattedColumn => !!column); + + if (visConfig.percentageCol) { + const insertAtIndex = formattedColumns.findIndex( + (col) => col.title === visConfig.percentageCol + ); + + // column to show percentage for was removed + if (insertAtIndex < 0) return { formattedRows, formattedColumns }; + + const { cols, rows } = addPercentageCol( + formattedColumns, + visConfig.percentageCol, + table.rows, + insertAtIndex + ); + formattedRows = rows; + formattedColumns = cols; + } + return { formattedRows, formattedColumns }; +}; diff --git a/src/plugins/vis_type_table_new/public/utils/index.ts b/src/plugins/vis_type_table_new/public/utils/index.ts new file mode 100644 index 000000000000..1fd0e3f1e0fd --- /dev/null +++ b/src/plugins/vis_type_table_new/public/utils/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './convert_to_csv_data'; +export * from './convert_to_formatted_data'; +export * from './use_pagination'; diff --git a/src/plugins/vis_type_table_new/public/utils/use_pagination.ts b/src/plugins/vis_type_table_new/public/utils/use_pagination.ts new file mode 100644 index 000000000000..41bb31d34086 --- /dev/null +++ b/src/plugins/vis_type_table_new/public/utils/use_pagination.ts @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { TableVisConfig } from '../types'; + +export const usePagination = (visConfig: TableVisConfig, nRow: number) => { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: visConfig.perPage || 0, + }); + const onChangeItemsPerPage = useCallback( + (pageSize) => setPagination((p) => ({ ...p, pageSize, pageIndex: 0 })), + [setPagination] + ); + const onChangePage = useCallback((pageIndex) => setPagination((p) => ({ ...p, pageIndex })), [ + setPagination, + ]); + + useEffect(() => { + const perPage = visConfig.perPage || 0; + const maxiPageIndex = Math.ceil(nRow / perPage) - 1; + setPagination((p) => ({ + pageIndex: p.pageIndex > maxiPageIndex ? maxiPageIndex : p.pageIndex, + pageSize: perPage, + })); + }, [nRow, visConfig.perPage]); + + return useMemo( + () => ({ + ...pagination, + onChangeItemsPerPage, + onChangePage, + }), + [pagination, onChangeItemsPerPage, onChangePage] + ); +}; diff --git a/src/plugins/vis_type_table_new/server/index.ts b/src/plugins/vis_type_table_new/server/index.ts new file mode 100644 index 000000000000..e0c27a37755c --- /dev/null +++ b/src/plugins/vis_type_table_new/server/index.ts @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginConfigDescriptor } from 'opensearch-dashboards/server'; + +import { configSchema, ConfigSchema } from '../config'; + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export const plugin = () => ({ + setup() {}, + start() {}, +});