diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.allowsnumericalaggregations.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.allowsnumericalaggregations.md new file mode 100644 index 0000000000000..454a816a60171 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.allowsnumericalaggregations.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [FieldFormat](./kibana-plugin-plugins-data-public.fieldformat.md) > [allowsNumericalAggregations](./kibana-plugin-plugins-data-public.fieldformat.allowsnumericalaggregations.md) + +## FieldFormat.allowsNumericalAggregations property + +Signature: + +```typescript +allowsNumericalAggregations?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.md index b53e301c46c1c..c956ffffd85ec 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformat.md @@ -21,6 +21,7 @@ export declare abstract class FieldFormat | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [\_params](./kibana-plugin-plugins-data-public.fieldformat._params.md) | | any | | +| [allowsNumericalAggregations](./kibana-plugin-plugins-data-public.fieldformat.allowsnumericalaggregations.md) | | boolean | | | [convertObject](./kibana-plugin-plugins-data-public.fieldformat.convertobject.md) | | FieldFormatConvert | undefined | {FieldFormatConvert} have to remove the private because of https://github.com/Microsoft/TypeScript/issues/17293 | | [fieldType](./kibana-plugin-plugins-data-public.fieldformat.fieldtype.md) | static | string | string[] | {string} - Field Format Type | | [getConfig](./kibana-plugin-plugins-data-public.fieldformat.getconfig.md) | | FieldFormatsGetConfigFn | undefined | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldformat.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldformat.md index 0937706d6b0e9..4fe738ddef5dc 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldformat.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.ifieldformat.md @@ -7,5 +7,5 @@ Signature: ```typescript -export declare type IFieldFormat = PublicMethodsOf; +export declare type IFieldFormat = FieldFormat; ``` diff --git a/scripts/functional_tests.js b/scripts/functional_tests.js index 196a13fbb2133..632a760d605b0 100644 --- a/scripts/functional_tests.js +++ b/scripts/functional_tests.js @@ -24,6 +24,7 @@ const alwaysImportedTests = [ require.resolve('../test/ui_capabilities/newsfeed_err/config.ts'), require.resolve('../test/new_visualize_flow/config.ts'), require.resolve('../test/security_functional/config.ts'), + require.resolve('../test/functional/config.legacy.ts'), ]; // eslint-disable-next-line no-restricted-syntax const onlyNotInCoverageTests = [ diff --git a/src/plugins/data/common/field_formats/field_format.ts b/src/plugins/data/common/field_formats/field_format.ts index 15ecf6e4fc3ef..e999e80d26e98 100644 --- a/src/plugins/data/common/field_formats/field_format.ts +++ b/src/plugins/data/common/field_formats/field_format.ts @@ -83,6 +83,7 @@ export abstract class FieldFormat { * @private */ public type: any = this.constructor; + public allowsNumericalAggregations?: boolean; protected readonly _params: any; protected getConfig: FieldFormatsGetConfigFn | undefined; diff --git a/src/plugins/data/common/field_formats/types.ts b/src/plugins/data/common/field_formats/types.ts index 5a830586b8d05..90d2c1ed38245 100644 --- a/src/plugins/data/common/field_formats/types.ts +++ b/src/plugins/data/common/field_formats/types.ts @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import { PublicMethodsOf } from '@kbn/utility-types'; import { GetConfigFn } from '../types'; import { FieldFormat } from './field_format'; import { FieldFormatsRegistry } from './field_formats_registry'; @@ -77,7 +76,7 @@ export interface FieldFormatConfig { export type FieldFormatsGetConfigFn = GetConfigFn; -export type IFieldFormat = PublicMethodsOf; +export type IFieldFormat = FieldFormat; /** * @string id type is needed for creating custom converters. diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 35546c33aaa80..656034546d02f 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -850,6 +850,8 @@ export const extractSearchSourceReferences: (state: SearchSourceFields) => [Sear export abstract class FieldFormat { // Warning: (ae-forgotten-export) The symbol "IFieldFormatMetaParams" needs to be exported by the entry point index.d.ts constructor(_params?: IFieldFormatMetaParams, getConfig?: FieldFormatsGetConfigFn); + // (undocumented) + allowsNumericalAggregations?: boolean; // Warning: (ae-forgotten-export) The symbol "HtmlContextTypeOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "TextContextTypeOptions" needs to be exported by the entry point index.d.ts convert(value: any, contentType?: FieldFormatsContentType, options?: HtmlContextTypeOptions | TextContextTypeOptions): string; @@ -1091,7 +1093,7 @@ export type IEsSearchResponse = IKibanaSearchResponse; +export type IFieldFormat = FieldFormat; // Warning: (ae-missing-release-tag) "IFieldFormatsRegistry" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/src/plugins/vis_type_table/README.md b/src/plugins/vis_type_table/README.md index cf37e133ed1cf..a17d1142f0c09 100644 --- a/src/plugins/vis_type_table/README.md +++ b/src/plugins/vis_type_table/README.md @@ -1 +1,8 @@ -Contains the data table visualization, that allows presenting data in a simple table format. \ No newline at end of file +Contains the data table visualization, that allows presenting data in a simple table format. + +By default a new version of visualization will be used. To use the previous version of visualization the config must have the `vis_type_table.legacyVisEnabled: true` setting +configured in `kibana.dev.yml` or `kibana.yml`, as shown in the example below: + +```yaml +vis_type_table.legacyVisEnabled: true +``` \ No newline at end of file diff --git a/src/plugins/vis_type_table/config.ts b/src/plugins/vis_type_table/config.ts index 6749bd83de39f..de888fa42018a 100644 --- a/src/plugins/vis_type_table/config.ts +++ b/src/plugins/vis_type_table/config.ts @@ -21,6 +21,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), + legacyVisEnabled: schema.boolean({ defaultValue: false }), }); export type ConfigSchema = TypeOf; diff --git a/src/plugins/vis_type_table/kibana.json b/src/plugins/vis_type_table/kibana.json index b8a68909dc857..dce9bce0e8886 100644 --- a/src/plugins/vis_type_table/kibana.json +++ b/src/plugins/vis_type_table/kibana.json @@ -11,8 +11,10 @@ ], "requiredBundles": [ "kibanaUtils", + "kibanaReact", "share", "charts", "visDefaultEditor" - ] + ], + "optionalPlugins": ["usageCollection"] } diff --git a/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap index bb63a6f4e5e6a..abe6f01c17e65 100644 --- a/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_type_table/public/__snapshots__/to_ast.test.ts.snap @@ -35,7 +35,7 @@ Object { Object { "arguments": Object { "visConfig": Array [ - "{\\"perPage\\":20,\\"percentageCol\\":\\"Count\\",\\"showMetricsAtAllLevels\\":true,\\"showPartialRows\\":true,\\"showTotal\\":true,\\"sort\\":{\\"columnIndex\\":null,\\"direction\\":null},\\"totalFunc\\":\\"sum\\",\\"dimensions\\":{\\"metrics\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{},\\"label\\":\\"Count\\",\\"aggType\\":\\"count\\"}],\\"buckets\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"YYYY-MM-DD HH:mm\\"}},\\"params\\":{},\\"label\\":\\"order_date per 3 hours\\",\\"aggType\\":\\"date_histogram\\"}]}}", + "{\\"perPage\\":20,\\"percentageCol\\":\\"Count\\",\\"showMetricsAtAllLevels\\":true,\\"showPartialRows\\":true,\\"showTotal\\":true,\\"showToolbar\\":false,\\"totalFunc\\":\\"sum\\",\\"dimensions\\":{\\"metrics\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{},\\"label\\":\\"Count\\",\\"aggType\\":\\"count\\"}],\\"buckets\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"YYYY-MM-DD HH:mm\\"}},\\"params\\":{},\\"label\\":\\"order_date per 3 hours\\",\\"aggType\\":\\"date_histogram\\"}]}}", ], }, "function": "kibana_table", diff --git a/src/plugins/vis_type_table/public/components/index.ts b/src/plugins/vis_type_table/public/components/index.ts new file mode 100644 index 0000000000000..1ae21997325b4 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { TableOptions } from './table_vis_options_lazy'; diff --git a/src/plugins/vis_type_table/public/components/table_vis_basic.tsx b/src/plugins/vis_type_table/public/components/table_vis_basic.tsx new file mode 100644 index 0000000000000..66abc71cf113c --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_basic.tsx @@ -0,0 +1,164 @@ +/* + * 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 React, { memo, useCallback, useMemo } from 'react'; +import { EuiDataGrid, EuiDataGridProps, EuiDataGridSorting, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { orderBy } from 'lodash'; + +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { createTableVisCell } from './table_vis_cell'; +import { Table } from '../table_vis_response_handler'; +import { TableVisConfig, TableVisUseUiStateProps } from '../types'; +import { useFormattedColumnsAndRows, usePagination } from '../utils'; +import { TableVisControls } from './table_vis_controls'; +import { createGridColumns } from './table_vis_columns'; + +interface TableVisBasicProps { + fireEvent: IInterpreterRenderHandlers['event']; + table: Table; + visConfig: TableVisConfig; + title?: string; + uiStateProps: TableVisUseUiStateProps; +} + +export const TableVisBasic = memo( + ({ + fireEvent, + table, + visConfig, + title, + uiStateProps: { columnsWidth, sort, setColumnsWidth, setSort }, + }: TableVisBasicProps) => { + const { columns, rows } = useFormattedColumnsAndRows(table, visConfig); + + // custom sorting is in place until the EuiDataGrid sorting gets rid of flaws -> https://github.com/elastic/eui/issues/4108 + const sortedRows = useMemo( + () => + sort.columnIndex !== null && sort.direction + ? orderBy(rows, columns[sort.columnIndex]?.id, sort.direction) + : rows, + [columns, rows, sort] + ); + + // renderCellValue is a component which renders a cell based on column and row indexes + const renderCellValue = useMemo(() => createTableVisCell(columns, sortedRows), [ + columns, + sortedRows, + ]); + + // Columns config + const gridColumns = createGridColumns(table, columns, columnsWidth, sortedRows, fireEvent); + + // Pagination config + const pagination = usePagination(visConfig, rows.length); + // Sorting config + const sortingColumns = useMemo( + () => + sort.columnIndex !== null && sort.direction + ? [{ id: columns[sort.columnIndex]?.id, direction: sort.direction }] + : [], + [columns, sort] + ); + const onSort = useCallback( + (sortingCols: EuiDataGridSorting['columns'] | []) => { + // data table vis sorting now only handles one column sorting + // if data grid provides more columns to sort, pick only the next column to sort + const newSortValue = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; + setSort( + newSortValue && { + columnIndex: columns.findIndex((c) => c.id === newSortValue.id), + direction: newSortValue.direction, + } + ); + }, + [columns, setSort] + ); + + const dataGridAriaLabel = + title || + visConfig.title || + i18n.translate('visTypeTable.defaultAriaLabel', { + defaultMessage: 'Data table visualization', + }); + + const onColumnResize: EuiDataGridProps['onColumnResize'] = useCallback( + ({ columnId, width }) => { + const colIndex = columns.findIndex((c) => c.id === columnId); + setColumnsWidth({ + colIndex, + width, + }); + }, + [columns, setColumnsWidth] + ); + + return ( + <> + {title && ( + +

{title}

+
+ )} + id), + setVisibleColumns: () => {}, + }} + toolbarVisibility={ + visConfig.showToolbar && { + showColumnSelector: false, + showFullScreenSelector: false, + showSortSelector: false, + showStyleSelector: false, + additionalControls: ( + + ), + } + } + renderCellValue={renderCellValue} + renderFooterCellValue={ + visConfig.showTotal + ? // @ts-expect-error + ({ colIndex }) => columns[colIndex].formattedTotal || null + : undefined + } + pagination={pagination} + sorting={{ columns: sortingColumns, onSort }} + onColumnResize={onColumnResize} + minSizeForControls={1} + /> + + ); + } +); diff --git a/src/plugins/vis_type_table/public/components/table_vis_cell.tsx b/src/plugins/vis_type_table/public/components/table_vis_cell.tsx new file mode 100644 index 0000000000000..08a9873cb1312 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_cell.tsx @@ -0,0 +1,50 @@ +/* + * 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 React from 'react'; +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; + +import { Table } from '../table_vis_response_handler'; +import { FormattedColumn } from '../types'; + +export const createTableVisCell = (formattedColumns: FormattedColumn[], rows: Table['rows']) => ({ + // @ts-expect-error + colIndex, + rowIndex, + columnId, +}: EuiDataGridCellValueElementProps) => { + const rowValue = rows[rowIndex][columnId]; + const column = formattedColumns[colIndex]; + const content = column.formatter.convert(rowValue, 'html'); + + const cellContent = ( +
+ ); + + return cellContent; +}; diff --git a/src/plugins/vis_type_table/public/components/table_vis_columns.tsx b/src/plugins/vis_type_table/public/components/table_vis_columns.tsx new file mode 100644 index 0000000000000..175a7aeffb713 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_columns.tsx @@ -0,0 +1,185 @@ +/* + * 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 React from 'react'; +import { EuiDataGridColumnCellActionProps, EuiDataGridColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { Table } from '../table_vis_response_handler'; +import { FormattedColumn, TableVisUiState } from '../types'; + +interface FilterCellData { + /** + * Row index + */ + row: number; + /** + * Column index + */ + column: number; + value: unknown; +} + +export const createGridColumns = ( + table: Table, + columns: FormattedColumn[], + columnsWidth: TableVisUiState['colWidth'], + rows: Table['rows'], + fireEvent: IInterpreterRenderHandlers['event'] +) => { + const onFilterClick = (data: FilterCellData, negate: boolean) => { + /** + * Visible column index and the actual one from the source table could be different. + * e.x. a column could be filtered out if it's not a dimension - + * see formattedColumns in use_formatted_columns.ts file, + * or an extra percantage column could be added, which doesn't exist in the raw table + */ + const rawTableActualColumnIndex = table.columns.findIndex( + (c) => c.id === columns[data.column].id + ); + fireEvent({ + name: 'filterBucket', + data: { + data: [ + { + table: { + ...table, + rows, + }, + ...data, + column: rawTableActualColumnIndex, + }, + ], + negate, + }, + }); + }; + + return columns.map( + (col, colIndex): EuiDataGridColumn => { + const cellActions = col.filterable + ? [ + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const rowValue = rows[rowIndex][columnId]; + const contentsIsDefined = rowValue !== null && rowValue !== undefined; + const cellContent = col.formatter.convert(rowValue); + + const filterForText = i18n.translate( + 'visTypeTable.tableCellFilter.filterForValueText', + { + defaultMessage: 'Filter for value', + } + ); + const filterForAriaLabel = i18n.translate( + 'visTypeTable.tableCellFilter.filterForValueAriaLabel', + { + defaultMessage: 'Filter for value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + { + onFilterClick({ row: rowIndex, column: colIndex, value: rowValue }, false); + closePopover(); + }} + iconType="plusInCircle" + > + {filterForText} + + ) + ); + }, + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const rowValue = rows[rowIndex][columnId]; + const contentsIsDefined = rowValue !== null && rowValue !== undefined; + const cellContent = col.formatter.convert(rowValue); + + const filterOutText = i18n.translate( + 'visTypeTable.tableCellFilter.filterOutValueText', + { + defaultMessage: 'Filter out value', + } + ); + const filterOutAriaLabel = i18n.translate( + 'visTypeTable.tableCellFilter.filterOutValueAriaLabel', + { + defaultMessage: 'Filter out value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + { + onFilterClick({ row: rowIndex, column: colIndex, value: rowValue }, true); + closePopover(); + }} + iconType="minusInCircle" + > + {filterOutText} + + ) + ); + }, + ] + : undefined; + + const initialWidth = columnsWidth.find((c) => c.colIndex === colIndex); + const column: EuiDataGridColumn = { + id: col.id, + display: col.title, + displayAsText: col.title, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: { + label: i18n.translate('visTypeTable.sort.ascLabel', { + defaultMessage: 'Sort asc', + }), + }, + showSortDesc: { + label: i18n.translate('visTypeTable.sort.descLabel', { + defaultMessage: 'Sort desc', + }), + }, + }, + cellActions, + }; + + if (initialWidth) { + column.initialWidth = initialWidth.width; + } + + return column; + } + ); +}; diff --git a/src/plugins/vis_type_table/public/components/table_vis_controls.tsx b/src/plugins/vis_type_table/public/components/table_vis_controls.tsx new file mode 100644 index 0000000000000..d8fecbfea5a0a --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_controls.tsx @@ -0,0 +1,102 @@ +/* + * 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 React, { memo, useState, useCallback } from 'react'; +import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { DatatableRow } from 'src/plugins/expressions'; +import { CoreStart } from 'kibana/public'; +import { useKibana } from '../../../kibana_react/public'; +import { FormattedColumn } from '../types'; +import { Table } from '../table_vis_response_handler'; +import { exportAsCsv } from '../utils'; + +interface TableVisControlsProps { + dataGridAriaLabel: string; + filename?: string; + cols: FormattedColumn[]; + rows: DatatableRow[]; + table: Table; +} + +export const TableVisControls = memo(({ dataGridAriaLabel, ...props }: TableVisControlsProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const togglePopover = useCallback(() => setIsPopoverOpen((state) => !state), []); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + + const { + services: { uiSettings }, + } = useKibana(); + + const onClickExport = useCallback( + (formatted: boolean) => + exportAsCsv(formatted, { + ...props, + uiSettings, + }), + [props, uiSettings] + ); + + const exportBtnAriaLabel = i18n.translate('visTypeTable.vis.controls.exportButtonAriaLabel', { + defaultMessage: 'Export {dataGridAriaLabel} as CSV', + values: { + dataGridAriaLabel, + }, + }); + + const button = ( + + + + ); + + const items = [ + onClickExport(false)}> + + , + onClickExport(true)}> + + , + ]; + + return ( + + + + ); +}); diff --git a/src/plugins/vis_type_table/public/components/table_vis_options.tsx b/src/plugins/vis_type_table/public/components/table_vis_options.tsx index c4d333134237a..b81f0425011da 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_options.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_options.tsx @@ -114,6 +114,15 @@ function TableOptions({ data-test-subj="showPartialRows" /> + + { + return ( + <> + {tables.map(({ tables: dataTable, key, title }) => ( +
+ +
+ ))} + + ); + } +); diff --git a/src/plugins/vis_type_table/public/components/table_visualization.scss b/src/plugins/vis_type_table/public/components/table_visualization.scss new file mode 100644 index 0000000000000..7bc51ed5c3d93 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_visualization.scss @@ -0,0 +1,33 @@ +// Prefix all styles with "tbv" to avoid conflicts. +// Examples +// tbvChart +// tbvChart__legend +// tbvChart__legend--small +// tbvChart__legend-isLoading + +.tbvChart { + display: flex; + flex-direction: column; + flex: 1 0 0; + overflow: auto; + + @include euiScrollBar; +} + +.tbvChart__split { + padding: $euiSizeS; + margin-bottom: $euiSizeL; + + > h3 { + text-align: center; + } +} + +.tbvChart__splitColumns { + flex-direction: row; + align-items: flex-start; +} + +.tbvChartCellContent { + @include euiTextTruncate; +} diff --git a/src/plugins/vis_type_table/public/components/table_visualization.tsx b/src/plugins/vis_type_table/public/components/table_visualization.tsx new file mode 100644 index 0000000000000..2d38acc57519f --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_visualization.tsx @@ -0,0 +1,86 @@ +/* + * 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 './table_visualization.scss'; +import React, { useEffect } from 'react'; +import classNames from 'classnames'; + +import { CoreStart } from 'kibana/public'; +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { KibanaContextProvider } from '../../../kibana_react/public'; +import { TableVisConfig } from '../types'; +import { TableContext } from '../table_vis_response_handler'; +import { TableVisBasic } from './table_vis_basic'; +import { TableVisSplit } from './table_vis_split'; +import { useUiState } from '../utils'; + +interface TableVisualizationComponentProps { + core: CoreStart; + handlers: IInterpreterRenderHandlers; + visData: TableContext; + visConfig: TableVisConfig; +} + +const TableVisualizationComponent = ({ + core, + handlers, + visData: { direction, table, tables }, + visConfig, +}: TableVisualizationComponentProps) => { + useEffect(() => { + handlers.done(); + }, [handlers]); + + const uiStateProps = useUiState(handlers.uiState); + + const className = classNames('tbvChart', { + // eslint-disable-next-line @typescript-eslint/naming-convention + tbvChart__splitColumns: direction === 'column', + }); + + return ( + + +
+ {table ? ( +
+ +
+ ) : ( + + )} +
+
+
+ ); +}; + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { TableVisualizationComponent as default }; diff --git a/src/plugins/vis_type_table/public/legacy/__snapshots__/table_vis_legacy_fn.test.ts.snap b/src/plugins/vis_type_table/public/legacy/__snapshots__/table_vis_legacy_fn.test.ts.snap new file mode 100644 index 0000000000000..a32609c2e3d34 --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/__snapshots__/table_vis_legacy_fn.test.ts.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`interpreter/functions#table returns an object with the correct structure 1`] = ` +Object { + "as": "table_vis", + "type": "render", + "value": Object { + "visConfig": Object { + "dimensions": Object { + "buckets": Array [], + "metrics": Array [ + Object { + "accessor": 0, + "aggType": "count", + "format": Object { + "id": "number", + }, + "params": Object {}, + }, + ], + }, + "perPage": 10, + "showMetricsAtAllLevels": false, + "showPartialRows": false, + "showTotal": false, + "sort": Object { + "columnIndex": null, + "direction": null, + }, + "title": "My Chart title", + "totalFunc": "sum", + }, + "visData": Object { + "tables": Array [ + Object { + "columns": Array [], + "rows": Array [], + }, + ], + }, + "visType": "table", + }, +} +`; diff --git a/src/plugins/vis_type_table/public/legacy/index.ts b/src/plugins/vis_type_table/public/legacy/index.ts new file mode 100644 index 0000000000000..a81b88aaf32f2 --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { registerLegacyVis } from './register_legacy_vis'; diff --git a/src/plugins/vis_type_table/public/legacy/register_legacy_vis.ts b/src/plugins/vis_type_table/public/legacy/register_legacy_vis.ts new file mode 100644 index 0000000000000..4e59378eb93ef --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/register_legacy_vis.ts @@ -0,0 +1,37 @@ +/* + * 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 { METRIC_TYPE } from '@kbn/analytics'; +import { PluginInitializerContext, CoreSetup } from 'kibana/public'; + +import { TablePluginSetupDependencies, TablePluginStartDependencies } from '../plugin'; +import { createTableVisLegacyFn } from './table_vis_legacy_fn'; +import { getTableVisLegacyRenderer } from './table_vis_legacy_renderer'; +import { tableVisLegacyTypeDefinition } from './table_vis_legacy_type'; + +export const registerLegacyVis = ( + core: CoreSetup, + { expressions, visualizations, usageCollection }: TablePluginSetupDependencies, + context: PluginInitializerContext +) => { + usageCollection?.reportUiCounter('vis_type_table', METRIC_TYPE.LOADED, 'legacyVisEnabled'); + expressions.registerFunction(createTableVisLegacyFn); + expressions.registerRenderer(getTableVisLegacyRenderer(core, context)); + visualizations.createBaseVisualization(tableVisLegacyTypeDefinition); +}; diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_controller.test.ts b/src/plugins/vis_type_table/public/legacy/table_vis_controller.test.ts index a2c5fc2c7de72..4a76b09b4177e 100644 --- a/src/plugins/vis_type_table/public/legacy/table_vis_controller.test.ts +++ b/src/plugins/vis_type_table/public/legacy/table_vis_controller.test.ts @@ -24,10 +24,10 @@ import $ from 'jquery'; import { getAngularModule } from './get_inner_angular'; import { initTableVisLegacyModule } from './table_vis_legacy_module'; -import { tableVisTypeDefinition } from '../table_vis_type'; +import { tableVisLegacyTypeDefinition } from './table_vis_legacy_type'; import { Vis } from '../../../visualizations/public'; import { stubFields } from '../../../data/public/stubs'; -import { tableVisResponseHandler } from '../table_vis_response_handler'; +import { tableVisLegacyResponseHandler } from './table_vis_legacy_response_handler'; import { coreMock } from '../../../../core/public/mocks'; import { IAggConfig, search } from '../../../data/public'; import { getStubIndexPattern } from '../../../data/public/test_utils'; @@ -94,7 +94,7 @@ describe('Table Vis - Controller', () => { angular.mock.inject((_$rootScope_: IRootScopeService, _$compile_: ICompileService) => { $rootScope = _$rootScope_; $compile = _$compile_; - tableAggResponse = tableVisResponseHandler; + tableAggResponse = tableVisLegacyResponseHandler; }) ); @@ -110,8 +110,8 @@ describe('Table Vis - Controller', () => { function getRangeVis(params?: object) { return ({ - type: tableVisTypeDefinition, - params: Object.assign({}, tableVisTypeDefinition.visConfig?.defaults, params), + type: tableVisLegacyTypeDefinition, + params: Object.assign({}, tableVisLegacyTypeDefinition.visConfig?.defaults, params), data: { aggs: createAggConfigs(stubIndexPattern, [ { type: 'count', schema: 'metric' }, diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.test.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.test.ts new file mode 100644 index 0000000000000..3916f80c24f14 --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.test.ts @@ -0,0 +1,78 @@ +/* + * 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 { createTableVisLegacyFn } from './table_vis_legacy_fn'; +import { tableVisLegacyResponseHandler } from './table_vis_legacy_response_handler'; + +import { functionWrapper } from '../../../expressions/common/expression_functions/specs/tests/utils'; + +jest.mock('./table_vis_legacy_response_handler', () => ({ + tableVisLegacyResponseHandler: jest.fn().mockReturnValue({ + tables: [{ columns: [], rows: [] }], + }), +})); + +describe('interpreter/functions#table', () => { + const fn = functionWrapper(createTableVisLegacyFn()); + const context = { + type: 'datatable', + rows: [{ 'col-0-1': 0 }], + columns: [{ id: 'col-0-1', name: 'Count' }], + }; + const visConfig = { + title: 'My Chart title', + perPage: 10, + showPartialRows: false, + showMetricsAtAllLevels: false, + sort: { + columnIndex: null, + direction: null, + }, + showTotal: false, + totalFunc: 'sum', + dimensions: { + metrics: [ + { + accessor: 0, + format: { + id: 'number', + }, + params: {}, + aggType: 'count', + }, + ], + buckets: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an object with the correct structure', async () => { + const actual = await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); + expect(actual).toMatchSnapshot(); + }); + + it('calls response handler with correct values', async () => { + await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); + expect(tableVisLegacyResponseHandler).toHaveBeenCalledTimes(1); + expect(tableVisLegacyResponseHandler).toHaveBeenCalledWith(context, visConfig.dimensions); + }); +}); diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.ts new file mode 100644 index 0000000000000..fa8dd4ee6fecf --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_fn.ts @@ -0,0 +1,72 @@ +/* + * 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 '@kbn/i18n'; +import { ExpressionFunctionDefinition, Datatable, Render } from 'src/plugins/expressions/public'; +import { tableVisLegacyResponseHandler, TableContext } from './table_vis_legacy_response_handler'; +import { TableVisConfig } from '../types'; + +export type Input = Datatable; + +interface Arguments { + visConfig: string | null; +} + +export interface TableVisRenderValue { + visData: TableContext; + visType: 'table'; + visConfig: TableVisConfig; +} + +export type TableExpressionFunctionDefinition = ExpressionFunctionDefinition< + 'kibana_table', + Input, + Arguments, + Render +>; + +export const createTableVisLegacyFn = (): TableExpressionFunctionDefinition => ({ + name: 'kibana_table', + type: 'render', + inputTypes: ['datatable'], + help: i18n.translate('visTypeTable.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 = tableVisLegacyResponseHandler(input, visConfig.dimensions); + + return { + type: 'render', + as: 'table_vis', + value: { + visData: convertedData, + visType: 'table', + visConfig, + }, + }; + }, +}); diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_response_handler.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_response_handler.ts new file mode 100644 index 0000000000000..312fd28c942cb --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_response_handler.ts @@ -0,0 +1,99 @@ +/* + * 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 { Required } from '@kbn/utility-types'; + +import { getFormatService } from '../services'; +import { Dimensions } from '../types'; +import { Input } from './table_vis_legacy_fn'; + +export interface TableContext { + tables: Array; + direction?: 'row' | 'column'; +} + +export interface TableGroup { + $parent: TableContext; + table: Input; + tables: Table[]; + title: string; + name: string; + key: any; + column: number; + row: number; +} + +export interface Table { + $parent?: TableGroup; + columns: Input['columns']; + rows: Input['rows']; +} + +export function tableVisLegacyResponseHandler(table: Input, dimensions: Dimensions): TableContext { + const converted: TableContext = { + tables: [], + }; + + const split = dimensions.splitColumn || dimensions.splitRow; + + if (split) { + converted.direction = dimensions.splitRow ? 'row' : 'column'; + const splitColumnIndex = split[0].accessor; + const splitColumnFormatter = getFormatService().deserialize(split[0].format); + const splitColumn = table.columns[splitColumnIndex]; + const splitMap: Record = {}; + let splitIndex = 0; + + table.rows.forEach((row, rowIndex) => { + const splitValue = row[splitColumn.id]; + + if (!splitMap.hasOwnProperty(splitValue)) { + splitMap[splitValue] = splitIndex++; + const tableGroup: Required = { + $parent: converted, + title: `${splitColumnFormatter.convert(splitValue)}: ${splitColumn.name}`, + name: splitColumn.name, + key: splitValue, + column: splitColumnIndex, + row: rowIndex, + table, + tables: [], + }; + + tableGroup.tables.push({ + $parent: tableGroup, + columns: table.columns, + rows: [], + }); + + converted.tables.push(tableGroup); + } + + const tableIndex = splitMap[splitValue]; + (converted.tables[tableIndex] as TableGroup).tables[0].rows.push(row); + }); + } else { + converted.tables.push({ + columns: table.columns, + rows: table.rows, + }); + } + + return converted; +} diff --git a/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts new file mode 100644 index 0000000000000..5aef3fc26fa6c --- /dev/null +++ b/src/plugins/vis_type_table/public/legacy/table_vis_legacy_type.ts @@ -0,0 +1,95 @@ +/* + * 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 '@kbn/i18n'; +import { AggGroupNames } from '../../../data/public'; +import { Schemas } from '../../../vis_default_editor/public'; +import { BaseVisTypeOptions } from '../../../visualizations/public'; + +import { TableOptions } from '../components/table_vis_options_lazy'; +import { VIS_EVENT_TO_TRIGGER } from '../../../visualizations/public'; +import { toExpressionAst } from '../to_ast'; +import { TableVisParams } from '../types'; + +export const tableVisLegacyTypeDefinition: BaseVisTypeOptions = { + name: 'table', + title: i18n.translate('visTypeTable.tableVisTitle', { + defaultMessage: 'Data table', + }), + icon: 'visTable', + description: i18n.translate('visTypeTable.tableVisDescription', { + defaultMessage: 'Display data in rows and columns.', + }), + getSupportedTriggers: () => { + return [VIS_EVENT_TO_TRIGGER.filter]; + }, + visConfig: { + defaults: { + perPage: 10, + showPartialRows: false, + showMetricsAtAllLevels: false, + sort: { + columnIndex: null, + direction: null, + }, + showTotal: false, + totalFunc: 'sum', + percentageCol: '', + }, + }, + editorConfig: { + optionsTemplate: TableOptions, + schemas: new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeTable.tableVisEditorConfig.schemas.metricTitle', { + defaultMessage: 'Metric', + }), + aggFilter: ['!geo_centroid', '!geo_bounds'], + aggSettings: { + top_hits: { + allowStrings: true, + }, + }, + min: 1, + defaults: [{ type: 'count', schema: 'metric' }], + }, + { + group: AggGroupNames.Buckets, + name: 'bucket', + title: i18n.translate('visTypeTable.tableVisEditorConfig.schemas.bucketTitle', { + defaultMessage: 'Split rows', + }), + aggFilter: ['!filter'], + }, + { + group: AggGroupNames.Buckets, + name: 'split', + title: i18n.translate('visTypeTable.tableVisEditorConfig.schemas.splitTitle', { + defaultMessage: 'Split table', + }), + min: 0, + max: 1, + aggFilter: ['!filter'], + }, + ]), + }, + toExpressionAst, + hierarchicalData: (vis) => vis.params.showPartialRows || vis.params.showMetricsAtAllLevels, +}; diff --git a/src/plugins/vis_type_table/public/plugin.ts b/src/plugins/vis_type_table/public/plugin.ts index 35ef5fc831cb7..4ca00e67e2e9f 100644 --- a/src/plugins/vis_type_table/public/plugin.ts +++ b/src/plugins/vis_type_table/public/plugin.ts @@ -19,18 +19,21 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'kibana/public'; import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; import { VisualizationsSetup } from '../../visualizations/public'; +import { UsageCollectionSetup } from '../../usage_collection/public'; -import { createTableVisFn } from './table_vis_fn'; -import { tableVisTypeDefinition } from './table_vis_type'; import { DataPublicPluginStart } from '../../data/public'; import { setFormatService } from './services'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; -import { getTableVisLegacyRenderer } from './legacy/table_vis_legacy_renderer'; + +interface ClientConfigType { + legacyVisEnabled: boolean; +} /** @internal */ export interface TablePluginSetupDependencies { expressions: ReturnType; visualizations: VisualizationsSetup; + usageCollection?: UsageCollectionSetup; } /** @internal */ @@ -43,8 +46,7 @@ export interface TablePluginStartDependencies { export class TableVisPlugin implements Plugin, void, TablePluginSetupDependencies, TablePluginStartDependencies> { - initializerContext: PluginInitializerContext; - createBaseVisualization: any; + initializerContext: PluginInitializerContext; constructor(initializerContext: PluginInitializerContext) { this.initializerContext = initializerContext; @@ -52,11 +54,17 @@ export class TableVisPlugin public async setup( core: CoreSetup, - { expressions, visualizations }: TablePluginSetupDependencies + deps: TablePluginSetupDependencies ) { - expressions.registerFunction(createTableVisFn); - expressions.registerRenderer(getTableVisLegacyRenderer(core, this.initializerContext)); - visualizations.createBaseVisualization(tableVisTypeDefinition); + const { legacyVisEnabled } = this.initializerContext.config.get(); + + if (legacyVisEnabled) { + const { registerLegacyVis } = await import('./legacy'); + registerLegacyVis(core, deps, this.initializerContext); + } else { + const { registerTableVis } = await import('./register_vis'); + registerTableVis(core, deps, this.initializerContext); + } } public start(core: CoreStart, { data }: TablePluginStartDependencies) { diff --git a/src/plugins/vis_type_table/public/register_vis.ts b/src/plugins/vis_type_table/public/register_vis.ts new file mode 100644 index 0000000000000..efbd3ad4ef7a7 --- /dev/null +++ b/src/plugins/vis_type_table/public/register_vis.ts @@ -0,0 +1,36 @@ +/* + * 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, CoreSetup } from 'kibana/public'; + +import { TablePluginSetupDependencies, TablePluginStartDependencies } from './plugin'; +import { createTableVisFn } from './table_vis_fn'; +import { getTableVisRenderer } from './table_vis_renderer'; +import { tableVisTypeDefinition } from './table_vis_type'; + +export const registerTableVis = async ( + core: CoreSetup, + { expressions, visualizations }: TablePluginSetupDependencies, + context: PluginInitializerContext +) => { + const [coreStart] = await core.getStartServices(); + expressions.registerFunction(createTableVisFn); + expressions.registerRenderer(getTableVisRenderer(coreStart)); + visualizations.createBaseVisualization(tableVisTypeDefinition); +}; diff --git a/src/plugins/vis_type_table/public/table_vis_renderer.tsx b/src/plugins/vis_type_table/public/table_vis_renderer.tsx new file mode 100644 index 0000000000000..bb46b2e5bab9f --- /dev/null +++ b/src/plugins/vis_type_table/public/table_vis_renderer.tsx @@ -0,0 +1,59 @@ +/* + * 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 React, { lazy } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; + +import { CoreStart } from 'kibana/public'; +import { VisualizationContainer } from '../../visualizations/public'; +import { ExpressionRenderDefinition } from '../../expressions/common/expression_renderers'; +import { TableVisRenderValue } from './table_vis_fn'; + +const TableVisualizationComponent = lazy(() => import('./components/table_visualization')); + +export const getTableVisRenderer: ( + core: CoreStart +) => ExpressionRenderDefinition = (core) => ({ + name: 'table_vis', + reuseDomNode: true, + render: async (domNode, { visData, visConfig }, handlers) => { + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + const showNoResult = + visData.table?.rows.length === 0 || (!visData.table && visData.tables.length === 0); + + render( + + + , + domNode + ); + }, +}); diff --git a/src/plugins/vis_type_table/public/table_vis_response_handler.ts b/src/plugins/vis_type_table/public/table_vis_response_handler.ts index 4bf33c876dfff..060fe1fbd8cf6 100644 --- a/src/plugins/vis_type_table/public/table_vis_response_handler.ts +++ b/src/plugins/vis_type_table/public/table_vis_response_handler.ts @@ -21,78 +21,81 @@ import { Required } from '@kbn/utility-types'; import { getFormatService } from './services'; import { Input } from './table_vis_fn'; +import { Dimensions } from './types'; export interface TableContext { - tables: Array; + table?: Table; + tables: TableGroup[]; direction?: 'row' | 'column'; } export interface TableGroup { - $parent: TableContext; table: Input; tables: Table[]; title: string; name: string; - key: any; + key: string | number; column: number; row: number; } export interface Table { - $parent?: TableGroup; columns: Input['columns']; rows: Input['rows']; } -export function tableVisResponseHandler(table: Input, dimensions: any): TableContext { - const converted: TableContext = { - tables: [], - }; +export function tableVisResponseHandler(input: Input, dimensions: Dimensions): TableContext { + let table: Table | undefined; + let tables: TableGroup[] = []; + let direction: TableContext['direction']; const split = dimensions.splitColumn || dimensions.splitRow; if (split) { - converted.direction = dimensions.splitRow ? 'row' : 'column'; + tables = []; + direction = dimensions.splitRow ? 'row' : 'column'; const splitColumnIndex = split[0].accessor; const splitColumnFormatter = getFormatService().deserialize(split[0].format); - const splitColumn = table.columns[splitColumnIndex]; - const splitMap = {}; + const splitColumn = input.columns[splitColumnIndex]; + const splitMap: { [key: string]: number } = {}; let splitIndex = 0; - table.rows.forEach((row, rowIndex) => { - const splitValue: any = row[splitColumn.id]; + input.rows.forEach((row, rowIndex) => { + const splitValue: string | number = row[splitColumn.id]; - if (!splitMap.hasOwnProperty(splitValue as any)) { - (splitMap as any)[splitValue] = splitIndex++; + if (!splitMap.hasOwnProperty(splitValue)) { + splitMap[splitValue] = splitIndex++; const tableGroup: Required = { - $parent: converted, title: `${splitColumnFormatter.convert(splitValue)}: ${splitColumn.name}`, name: splitColumn.name, key: splitValue, column: splitColumnIndex, row: rowIndex, - table, + table: input, tables: [], }; tableGroup.tables.push({ - $parent: tableGroup, - columns: table.columns, + columns: input.columns, rows: [], }); - converted.tables.push(tableGroup); + tables.push(tableGroup); } - const tableIndex = (splitMap as any)[splitValue]; - (converted.tables[tableIndex] as any).tables[0].rows.push(row); + const tableIndex = splitMap[splitValue]; + tables[tableIndex].tables[0].rows.push(row); }); } else { - converted.tables.push({ - columns: table.columns, - rows: table.rows, - }); + table = { + columns: input.columns, + rows: input.rows, + }; } - return converted; + return { + direction, + table, + tables, + }; } diff --git a/src/plugins/vis_type_table/public/table_vis_type.ts b/src/plugins/vis_type_table/public/table_vis_type.ts index 8546886e8350e..bfe1427d38496 100644 --- a/src/plugins/vis_type_table/public/table_vis_type.ts +++ b/src/plugins/vis_type_table/public/table_vis_type.ts @@ -43,11 +43,8 @@ export const tableVisTypeDefinition: BaseVisTypeOptions = { perPage: 10, showPartialRows: false, showMetricsAtAllLevels: false, - sort: { - columnIndex: null, - direction: null, - }, showTotal: false, + showToolbar: false, totalFunc: 'sum', percentageCol: '', }, @@ -91,7 +88,5 @@ export const tableVisTypeDefinition: BaseVisTypeOptions = { ]), }, toExpressionAst, - hierarchicalData: (vis) => { - return Boolean(vis.params.showPartialRows || vis.params.showMetricsAtAllLevels); - }, + hierarchicalData: (vis) => vis.params.showPartialRows || vis.params.showMetricsAtAllLevels, }; diff --git a/src/plugins/vis_type_table/public/to_ast.test.ts b/src/plugins/vis_type_table/public/to_ast.test.ts index 045b5814944b0..0f4e4077dc840 100644 --- a/src/plugins/vis_type_table/public/to_ast.test.ts +++ b/src/plugins/vis_type_table/public/to_ast.test.ts @@ -70,7 +70,7 @@ describe('table vis toExpressionAst function', () => { showMetricsAtAllLevels: true, showPartialRows: true, showTotal: true, - sort: { columnIndex: null, direction: null }, + showToolbar: false, totalFunc: AggTypes.SUM, }; const actual = toExpressionAst(vis, {} as any); diff --git a/src/plugins/vis_type_table/public/to_ast.ts b/src/plugins/vis_type_table/public/to_ast.ts index 78d6efd31a115..b92aea97e3ac1 100644 --- a/src/plugins/vis_type_table/public/to_ast.ts +++ b/src/plugins/vis_type_table/public/to_ast.ts @@ -30,14 +30,15 @@ const buildTableVisConfig = ( schemas: ReturnType, visParams: TableVisParams ) => { - const visConfig = {} as any; const metrics = schemas.metric; const buckets = schemas.bucket || []; - visConfig.dimensions = { - metrics, - buckets, - splitRow: schemas.split_row, - splitColumn: schemas.split_column, + const visConfig = { + dimensions: { + metrics, + buckets, + splitRow: schemas.split_row, + splitColumn: schemas.split_column, + }, }; if (visParams.showPartialRows && !visParams.showMetricsAtAllLevels) { diff --git a/src/plugins/vis_type_table/public/types.ts b/src/plugins/vis_type_table/public/types.ts index c0a995ad5da69..d5d883b4c21bf 100644 --- a/src/plugins/vis_type_table/public/types.ts +++ b/src/plugins/vis_type_table/public/types.ts @@ -17,7 +17,8 @@ * under the License. */ -import { SchemaConfig } from '../../visualizations/public'; +import { IFieldFormat } from 'src/plugins/data/public'; +import { SchemaConfig } from 'src/plugins/visualizations/public'; export enum AggTypes { SUM = 'sum', @@ -30,16 +31,35 @@ export enum AggTypes { export interface Dimensions { buckets: SchemaConfig[]; metrics: SchemaConfig[]; + splitColumn?: SchemaConfig[]; + splitRow?: SchemaConfig[]; +} + +export interface ColumnWidthData { + colIndex: number; + width: number; +} + +export interface TableVisUiState { + sort: { + columnIndex: number | null; + direction: 'asc' | 'desc' | null; + }; + colWidth: ColumnWidthData[]; +} + +export interface TableVisUseUiStateProps { + columnsWidth: TableVisUiState['colWidth']; + sort: TableVisUiState['sort']; + setSort: (s?: TableVisUiState['sort']) => void; + setColumnsWidth: (column: ColumnWidthData) => void; } export interface TableVisParams { perPage: number | ''; showPartialRows: boolean; showMetricsAtAllLevels: boolean; - sort: { - columnIndex: number | null; - direction: string | null; - }; + showToolbar: boolean; showTotal: boolean; totalFunc: AggTypes; percentageCol: string; @@ -49,3 +69,13 @@ export interface TableVisConfig extends TableVisParams { title: string; dimensions: Dimensions; } + +export interface FormattedColumn { + id: string; + title: string; + formatter: IFieldFormat; + formattedTotal?: string | number; + filterable: boolean; + sumTotal?: number; + total?: number; +} diff --git a/src/plugins/vis_type_table/public/utils/add_percentage_column.ts b/src/plugins/vis_type_table/public/utils/add_percentage_column.ts new file mode 100644 index 0000000000000..947d68214050b --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/add_percentage_column.ts @@ -0,0 +1,64 @@ +/* + * 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 '@kbn/i18n'; +import { DatatableRow } from 'src/plugins/expressions'; +import { getFormatService } from '../services'; +import { FormattedColumn } from '../types'; +import { Table } from '../table_vis_response_handler'; + +function insertColumn(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) + */ +export function addPercentageColumn( + 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('visTypeTable.params.percentageTableColumnName', { + defaultMessage: '{title} percentages', + values: { title }, + }); + const newCols = insertColumn(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 }; +} diff --git a/src/plugins/vis_type_table/public/utils/export_as_csv.ts b/src/plugins/vis_type_table/public/utils/export_as_csv.ts new file mode 100644 index 0000000000000..3592fed08f18b --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/export_as_csv.ts @@ -0,0 +1,75 @@ +/* + * 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 'kibana/public'; +import { DatatableRow } from 'src/plugins/expressions'; +import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../../../share/public'; +import { FormattedColumn } from '../types'; +import { Table } from '../table_vis_response_handler'; + +const nonAlphaNumRE = /[^a-zA-Z0-9]/; +const allDoubleQuoteRE = /"/g; + +interface ToCsvData { + filename?: string; + cols: FormattedColumn[]; + rows: DatatableRow[]; + table: Table; + uiSettings: CoreStart['uiSettings']; +} + +const toCsv = (formatted: boolean, { cols, rows, table, uiSettings }: ToCsvData) => { + const separator = uiSettings.get(CSV_SEPARATOR_SETTING); + const quoteValues = uiSettings.get(CSV_QUOTE_VALUES_SETTING); + + function escape(val: unknown) { + if (!formatted && isObject(val)) val = val.valueOf(); + val = String(val); + if (quoteValues && nonAlphaNumRE.test(val as string)) { + val = '"' + (val as string).replace(allDoubleQuoteRE, '""') + '"'; + } + return val as string; + } + + const csvRows: string[][] = []; + + for (const row of rows) { + const rowArray: string[] = []; + for (const col of cols) { + const value = row[col.id]; + const formattedValue = formatted ? escape(col.formatter.convert(value)) : escape(value); + rowArray.push(formattedValue); + } + csvRows.push(rowArray); + } + + // add headers to the rows + csvRows.unshift(cols.map(({ title }) => escape(title))); + + return csvRows.map((row) => row.join(separator) + '\r\n').join(''); +}; + +export const exportAsCsv = (formatted: boolean, data: ToCsvData) => { + const csv = new Blob([toCsv(formatted, data)], { type: 'text/plain;charset=utf-8' }); + saveAs(csv, `${data.filename || 'unsaved'}.csv`); +}; diff --git a/src/plugins/vis_type_table/public/utils/index.ts b/src/plugins/vis_type_table/public/utils/index.ts new file mode 100644 index 0000000000000..ab8f4911a5fa2 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export * from './use'; +export * from './export_as_csv'; diff --git a/src/plugins/vis_type_table/public/utils/use/index.ts b/src/plugins/vis_type_table/public/utils/use/index.ts new file mode 100644 index 0000000000000..f515858a8f865 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/use/index.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export * from './use_formatted_columns'; +export * from './use_pagination'; +export * from './use_ui_state'; diff --git a/src/plugins/vis_type_table/public/utils/use/use_formatted_columns.ts b/src/plugins/vis_type_table/public/utils/use/use_formatted_columns.ts new file mode 100644 index 0000000000000..72138455f0c6e --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/use/use_formatted_columns.ts @@ -0,0 +1,125 @@ +/* + * 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 { useMemo } from 'react'; +import { chain, findIndex } from 'lodash'; + +import { Table } from '../../table_vis_response_handler'; +import { FormattedColumn, TableVisConfig, AggTypes } from '../../types'; +import { getFormatService } from '../../services'; +import { addPercentageColumn } from '../add_percentage_column'; + +export const useFormattedColumnsAndRows = (table: Table, visConfig: TableVisConfig) => { + const { formattedColumns: columns, formattedRows: rows } = useMemo(() => { + const { buckets, metrics } = visConfig.dimensions; + let formattedRows = table.rows; + + let formattedColumns = table.columns + .map((col, i) => { + const isBucket = buckets.find(({ accessor }) => accessor === i); + const dimension = isBucket || metrics.find(({ accessor }) => 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 sumOfColumnValues = 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 = sumOfColumnValues; + + switch (visConfig.totalFunc) { + case AggTypes.SUM: { + if (!isDate) { + formattedColumn.formattedTotal = formatter.convert(sumOfColumnValues); + formattedColumn.total = sumOfColumnValues; + } + break; + } + case AggTypes.AVG: { + if (!isDate) { + const total = sumOfColumnValues / 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 AggTypes.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 = findIndex(formattedColumns, { title: visConfig.percentageCol }); + + // column to show percentage for was removed + if (insertAtIndex < 0) return { formattedColumns, formattedRows }; + + const { cols, rows: rowsWithPercentage } = addPercentageColumn( + formattedColumns, + visConfig.percentageCol, + table.rows, + insertAtIndex + ); + + formattedRows = rowsWithPercentage; + formattedColumns = cols; + } + + return { formattedColumns, formattedRows }; + }, [table, visConfig.dimensions, visConfig.percentageCol, visConfig.totalFunc]); + + return { columns, rows }; +}; diff --git a/src/plugins/vis_type_table/public/utils/use/use_pagination.ts b/src/plugins/vis_type_table/public/utils/use/use_pagination.ts new file mode 100644 index 0000000000000..080f64b6a5743 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/use/use_pagination.ts @@ -0,0 +1,64 @@ +/* + * 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 { useCallback, useEffect, useMemo, useState } from 'react'; +import { TableVisParams } from '../../types'; + +export const usePagination = (visParams: TableVisParams, rowCount: number) => { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: visParams.perPage || 0, + }); + const onChangeItemsPerPage = useCallback( + (pageSize: number) => setPagination((pag) => ({ ...pag, pageSize, pageIndex: 0 })), + [] + ); + const onChangePage = useCallback( + (pageIndex: number) => setPagination((pag) => ({ ...pag, pageIndex })), + [] + ); + + useEffect(() => { + const pageSize = visParams.perPage || 0; + const lastPageIndex = Math.ceil(rowCount / pageSize) - 1; + /** + * When the underlying data changes, there might be a case when actual pagination page + * doesn't exist anymore - if the number of rows has decreased. + * Set the last page as an actual. + */ + setPagination((pag) => ({ + pageIndex: pag.pageIndex > lastPageIndex ? lastPageIndex : pag.pageIndex, + pageSize, + })); + }, [visParams.perPage, rowCount]); + + const paginationMemoized = useMemo( + () => + pagination.pageSize + ? { + ...pagination, + onChangeItemsPerPage, + onChangePage, + } + : undefined, + [onChangeItemsPerPage, onChangePage, pagination] + ); + + return paginationMemoized; +}; diff --git a/src/plugins/vis_type_table/public/utils/use/use_ui_state.ts b/src/plugins/vis_type_table/public/utils/use/use_ui_state.ts new file mode 100644 index 0000000000000..68bad16972ec2 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/use/use_ui_state.ts @@ -0,0 +1,139 @@ +/* + * 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 { debounce, isEqual } from 'lodash'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; + +import { ColumnWidthData, TableVisUiState, TableVisUseUiStateProps } from '../../types'; + +const defaultSort = { + columnIndex: null, + direction: null, +}; + +export const useUiState = ( + uiState: IInterpreterRenderHandlers['uiState'] +): TableVisUseUiStateProps => { + const [sort, setSortState] = useState( + uiState?.get('vis.params.sort') || defaultSort + ); + + const [columnsWidth, setColumnsWidthState] = useState( + uiState?.get('vis.params.colWidth') || [] + ); + + const uiStateValues = useRef<{ + columnsWidth: ColumnWidthData[]; + sort: TableVisUiState['sort']; + /** + * Property to filter out the changes, which were done internally via local state. + */ + pendingUpdate: boolean; + }>({ + columnsWidth: uiState?.get('vis.params.colWidth'), + sort: uiState?.get('vis.params.sort'), + pendingUpdate: false, + }); + + const setSort = useCallback( + (s: TableVisUiState['sort'] = defaultSort) => { + setSortState(s || defaultSort); + + uiStateValues.current.sort = s; + uiStateValues.current.pendingUpdate = true; + + /** + * Since the visualize app state is listening for uiState changes, + * it synchronously re-renders an editor frame. + * Setting new uiState values in the new event loop task, + * helps to update the visualization frame firstly and not to block the rendering flow + */ + setTimeout(() => { + uiState?.set('vis.params.sort', s); + uiStateValues.current.pendingUpdate = false; + }); + }, + [uiState] + ); + + const setColumnsWidth = useCallback( + (col: ColumnWidthData) => { + setColumnsWidthState((prevState) => { + const updated = [...prevState]; + const idx = prevState.findIndex((c) => c.colIndex === col.colIndex); + + if (idx >= 0) { + updated[idx] = col; + } else { + updated.push(col); + } + + uiStateValues.current.columnsWidth = updated; + uiStateValues.current.pendingUpdate = true; + + /** + * Since the visualize app state is listening for uiState changes, + * it synchronously re-renders an editor frame. + * Setting new uiState values in the new event loop task, + * helps to update the visualization frame firstly and not to block the rendering flow + */ + setTimeout(() => { + uiState?.set('vis.params.colWidth', updated); + uiStateValues.current.pendingUpdate = false; + }); + return updated; + }); + }, + [uiState] + ); + + useEffect(() => { + /** + * Debounce is in place since there are couple of synchronous updates of the uiState, + * which are also handled synchronously. + */ + const updateOnChange = debounce(() => { + // skip uiState updates if there are pending internal state updates + if (uiStateValues.current.pendingUpdate) { + return; + } + + const { vis } = uiState?.getChanges(); + + if (!isEqual(vis?.params.colWidth, uiStateValues.current.columnsWidth)) { + uiStateValues.current.columnsWidth = vis?.params.colWidth; + setColumnsWidthState(vis?.params.colWidth || []); + } + + if (!isEqual(vis?.params.sort, uiStateValues.current.sort)) { + uiStateValues.current.sort = vis?.params.sort; + setSortState(vis?.params.sort || defaultSort); + } + }); + + uiState?.on('change', updateOnChange); + + return () => { + uiState?.off('change', updateOnChange); + }; + }, [uiState]); + + return { columnsWidth, sort, setColumnsWidth, setSort }; +}; diff --git a/src/plugins/vis_type_table/server/index.ts b/src/plugins/vis_type_table/server/index.ts index 882958a28777d..c876b18f5d2cc 100644 --- a/src/plugins/vis_type_table/server/index.ts +++ b/src/plugins/vis_type_table/server/index.ts @@ -22,6 +22,9 @@ import { PluginConfigDescriptor } from 'kibana/server'; import { configSchema, ConfigSchema } from '../config'; export const config: PluginConfigDescriptor = { + exposeToBrowser: { + legacyVisEnabled: true, + }, schema: configSchema, deprecations: ({ renameFromRoot }) => [ renameFromRoot('table_vis.enabled', 'vis_type_table.enabled'), diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts index c4ee92194ec36..17c55af32005d 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts @@ -1654,4 +1654,26 @@ describe('migration visualization', () => { expect(attributes).toEqual(oldAttributes); }); }); + + describe('7.11.0 Data table vis - enable toolbar', () => { + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['7.11.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + + const testDoc = { + attributes: { + title: 'My data table vis', + description: 'Data table vis for test.', + visState: `{"type":"table","params": {"perPage": 10,"showPartialRows": false,"showTotal": false,"totalFunc": "sum"}}`, + }, + }; + + it('should enable toolbar in visState.params', () => { + const migratedDataTableVisDoc = migrate(testDoc); + const visState = JSON.parse(migratedDataTableVisDoc.attributes.visState); + expect(visState.params.showToolbar).toEqual(true); + }); + }); }); diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts index fbeefacf6035f..bdd87e355499b 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts @@ -757,6 +757,35 @@ const removeTSVBSearchSource: SavedObjectMigrationFn = (doc) => { return doc; }; +// [Data table visualization] Enable toolbar by default +const enableDataTableVisToolbar: SavedObjectMigrationFn = (doc) => { + let visState; + + try { + visState = JSON.parse(doc.attributes.visState); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + + if (visState?.type === 'table') { + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify({ + ...visState, + params: { + ...visState.params, + showToolbar: true, + }, + }), + }, + }; + } + + return doc; +}; + export const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -790,4 +819,5 @@ export const visualizationSavedObjectTypeMigrations = { '7.8.0': flow(migrateTsvbDefaultColorPalettes), '7.9.3': flow(migrateMatchAllQuery), '7.10.0': flow(migrateFilterRatioQuery, removeTSVBSearchSource), + '7.11.0': flow(enableDataTableVisToolbar), }; diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index 8e8730b1e574a..eb75358c7c77c 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -195,7 +195,7 @@ export default function ({ getService }) { }, id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', migrationVersion: { - visualization: '7.10.0', + visualization: '7.11.0', }, namespaces: ['foo-ns'], references: [ diff --git a/test/functional/apps/dashboard/dashboard_filtering.js b/test/functional/apps/dashboard/dashboard_filtering.js index b3812af38c348..45950963cfded 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.js +++ b/test/functional/apps/dashboard/dashboard_filtering.js @@ -84,7 +84,7 @@ export default function ({ getService, getPageObjects }) { }); it('data tables are filtered', async () => { - await dashboardExpect.dataTableRowCount(0); + await dashboardExpect.dataTableNoResult(); }); it('goal and guages are filtered', async () => { @@ -145,7 +145,7 @@ export default function ({ getService, getPageObjects }) { }); it('data tables are filtered', async () => { - await dashboardExpect.dataTableRowCount(0); + await dashboardExpect.dataTableNoResult(); }); it('goal and guages are filtered', async () => { diff --git a/test/functional/apps/dashboard/embeddable_rendering.js b/test/functional/apps/dashboard/embeddable_rendering.js index b7b795ae11c96..77fe28a01f86d 100644 --- a/test/functional/apps/dashboard/embeddable_rendering.js +++ b/test/functional/apps/dashboard/embeddable_rendering.js @@ -79,7 +79,7 @@ export default function ({ getService, getPageObjects }) { const expectNoDataRenders = async () => { await pieChart.expectPieSliceCount(0); await dashboardExpect.seriesElementCount(0); - await dashboardExpect.dataTableRowCount(0); + await dashboardExpect.dataTableNoResult(); await dashboardExpect.savedSearchRowCount(0); await dashboardExpect.inputControlItemCount(5); await dashboardExpect.metricValuesExist(['0']); diff --git a/test/functional/apps/visualize/_data_table.js b/test/functional/apps/visualize/_data_table.ts similarity index 87% rename from test/functional/apps/visualize/_data_table.js rename to test/functional/apps/visualize/_data_table.ts index 5b0b7af56b332..ab5ee31f8b00f 100644 --- a/test/functional/apps/visualize/_data_table.js +++ b/test/functional/apps/visualize/_data_table.ts @@ -18,8 +18,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const inspector = getService('inspector'); const testSubjects = getService('testSubjects'); @@ -97,12 +98,10 @@ export default function ({ getService, getPageObjects }) { it('should show percentage columns', async () => { async function expectValidTableData() { - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql([ - '≥ 0B and < 1,000B', - '1,351 64.703%', - '≥ 1,000B and < 1.953KB', - '737 35.297%', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['≥ 0B and < 1,000B', '1,351', '64.703%'], + ['≥ 1,000B and < 1.953KB', '737', '35.297%'], ]); } @@ -142,12 +141,10 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.clickGo(); await PageObjects.visEditor.clickOptionsTab(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql([ - '≥ 0B and < 1,000B', - '344.094B', - '≥ 1,000B and < 1.953KB', - '1.697KB', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['≥ 0B and < 1,000B', '344.094B'], + ['≥ 1,000B and < 1.953KB', '1.697KB'], ]); }); @@ -158,34 +155,11 @@ export default function ({ getService, getPageObjects }) { await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.visEditor.clickBucket('Metric', 'metrics'); await PageObjects.visEditor.selectAggregation('Average Bucket', 'metrics'); - await PageObjects.visEditor.selectAggregation('Terms', 'metrics', 'buckets'); - await PageObjects.visEditor.selectField('geo.src', 'metrics', 'buckets'); + await PageObjects.visEditor.selectAggregation('Terms', 'metrics', true); + await PageObjects.visEditor.selectField('geo.src', 'metrics', true); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql(['14,004 1,412.6']); - }); - - it('should show correct data for a data table with date histogram', async () => { - await PageObjects.visualize.navigateToNewAggBasedVisualization(); - await PageObjects.visualize.clickDataTable(); - await PageObjects.visualize.clickNewSearch(); - await PageObjects.timePicker.setDefaultAbsoluteRange(); - await PageObjects.visEditor.clickBucket('Split rows'); - await PageObjects.visEditor.selectAggregation('Date Histogram'); - await PageObjects.visEditor.selectField('@timestamp'); - await PageObjects.visEditor.setInterval('Day'); - await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-20', - '4,757', - '2015-09-21', - '4,614', - '2015-09-22', - '4,633', - ]); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['14,004', '1,412.6']]); }); it('should show correct data for a data table with date histogram', async () => { @@ -198,14 +172,11 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectField('@timestamp'); await PageObjects.visEditor.setInterval('Day'); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-20', - '4,757', - '2015-09-21', - '4,614', - '2015-09-22', - '4,633', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-20', '4,757'], + ['2015-09-21', '4,614'], + ['2015-09-22', '4,633'], ]); }); @@ -219,14 +190,11 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectField('UTC time'); await PageObjects.visEditor.setInterval('Day'); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-20', - '4,757', - '2015-09-21', - '4,614', - '2015-09-22', - '4,633', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-20', '4,757'], + ['2015-09-21', '4,614'], + ['2015-09-22', '4,633'], ]); const header = await PageObjects.visChart.getTableVisHeader(); expect(header).to.contain('UTC time'); @@ -235,15 +203,15 @@ export default function ({ getService, getPageObjects }) { it('should correctly filter for applied time filter on the main timefield', async () => { await filterBar.addFilter('@timestamp', 'is between', '2015-09-19', '2015-09-21'); await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['2015-09-20', '4,757']]); }); it('should correctly filter for pinned filters', async () => { await filterBar.toggleFilterPinned('@timestamp'); await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['2015-09-20', '4,757']]); }); it('should show correct data for a data table with top hits', async () => { @@ -255,7 +223,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectAggregation('Top Hit', 'metrics'); await PageObjects.visEditor.selectField('agent.raw', 'metrics'); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); + const data = await PageObjects.visChart.getTableVisContent(); log.debug(data); expect(data.length).to.be.greaterThan(0); }); @@ -269,12 +237,10 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectAggregation('Range'); await PageObjects.visEditor.selectField('bytes'); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql([ - '≥ 0B and < 1,000B', - '1,351', - '≥ 1,000B and < 1.953KB', - '737', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['≥ 0B and < 1,000B', '1,351'], + ['≥ 1,000B and < 1.953KB', '737'], ]); }); diff --git a/test/functional/apps/visualize/_data_table_nontimeindex.js b/test/functional/apps/visualize/_data_table_nontimeindex.ts similarity index 87% rename from test/functional/apps/visualize/_data_table_nontimeindex.js rename to test/functional/apps/visualize/_data_table_nontimeindex.ts index f45e40970a57f..247107a07a550 100644 --- a/test/functional/apps/visualize/_data_table_nontimeindex.js +++ b/test/functional/apps/visualize/_data_table_nontimeindex.ts @@ -18,8 +18,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const inspector = getService('inspector'); const retry = getService('retry'); @@ -104,12 +105,11 @@ export default function ({ getService, getPageObjects }) { ); await PageObjects.visEditor.clickBucket('Metric', 'metrics'); await PageObjects.visEditor.selectAggregation('Average Bucket', 'metrics'); - await PageObjects.visEditor.selectAggregation('Terms', 'metrics', 'buckets'); - await PageObjects.visEditor.selectField('geo.src', 'metrics', 'buckets'); + await PageObjects.visEditor.selectAggregation('Terms', 'metrics', true); + await PageObjects.visEditor.selectField('geo.src', 'metrics', true); await PageObjects.visEditor.clickGo(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql(['14,004 1,412.6']); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['14,004', '1,412.6']]); }); describe('data table with date histogram', async () => { @@ -127,15 +127,11 @@ export default function ({ getService, getPageObjects }) { }); it('should show correct data', async () => { - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-20', - '4,757', - '2015-09-21', - '4,614', - '2015-09-22', - '4,633', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-20', '4,757'], + ['2015-09-21', '4,614'], + ['2015-09-22', '4,633'], ]); }); @@ -143,16 +139,16 @@ export default function ({ getService, getPageObjects }) { await filterBar.addFilter('@timestamp', 'is between', '2015-09-19', '2015-09-21'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['2015-09-20', '4,757']]); }); it('should correctly filter for pinned filters', async () => { await filterBar.toggleFilterPinned('@timestamp'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisData(); - expect(data.trim().split('\n')).to.be.eql(['2015-09-20', '4,757']); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([['2015-09-20', '4,757']]); }); }); }); diff --git a/test/functional/apps/visualize/_data_table_notimeindex_filters.ts b/test/functional/apps/visualize/_data_table_notimeindex_filters.ts index cdcf08d2514e5..d847d3bf4435c 100644 --- a/test/functional/apps/visualize/_data_table_notimeindex_filters.ts +++ b/test/functional/apps/visualize/_data_table_notimeindex_filters.ts @@ -77,7 +77,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardAddPanel.addVisualization(vizName1); // hover and click on cell to filter - await PageObjects.visChart.filterOnTableCell('1', '2'); + await PageObjects.visChart.filterOnTableCell(1, 2); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); diff --git a/test/functional/apps/visualize/_embedding_chart.js b/test/functional/apps/visualize/_embedding_chart.ts similarity index 54% rename from test/functional/apps/visualize/_embedding_chart.js rename to test/functional/apps/visualize/_embedding_chart.ts index 773bd63d8892f..9a8bb7dde442d 100644 --- a/test/functional/apps/visualize/_embedding_chart.js +++ b/test/functional/apps/visualize/_embedding_chart.ts @@ -18,10 +18,10 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const filterBar = getService('filterBar'); - const log = getService('log'); const renderable = getService('renderable'); const embedding = getService('embedding'); const PageObjects = getPageObjects([ @@ -54,39 +54,18 @@ export default function ({ getService, getPageObjects }) { await embedding.openInEmbeddedMode(); await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-20 00:00', - '0B', - '5', - '2015-09-20 00:00', - '1.953KB', - '5', - '2015-09-20 00:00', - '3.906KB', - '9', - '2015-09-20 00:00', - '5.859KB', - '4', - '2015-09-20 00:00', - '7.813KB', - '14', - '2015-09-20 03:00', - '0B', - '32', - '2015-09-20 03:00', - '1.953KB', - '33', - '2015-09-20 03:00', - '3.906KB', - '45', - '2015-09-20 03:00', - '5.859KB', - '31', - '2015-09-20 03:00', - '7.813KB', - '48', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-20 00:00', '0B', '5'], + ['2015-09-20 00:00', '1.953KB', '5'], + ['2015-09-20 00:00', '3.906KB', '9'], + ['2015-09-20 00:00', '5.859KB', '4'], + ['2015-09-20 00:00', '7.813KB', '14'], + ['2015-09-20 03:00', '0B', '32'], + ['2015-09-20 03:00', '1.953KB', '33'], + ['2015-09-20 03:00', '3.906KB', '45'], + ['2015-09-20 03:00', '5.859KB', '31'], + ['2015-09-20 03:00', '7.813KB', '48'], ]); }); @@ -95,39 +74,18 @@ export default function ({ getService, getPageObjects }) { await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql([ - '2015-09-21 00:00', - '0B', - '7', - '2015-09-21 00:00', - '1.953KB', - '9', - '2015-09-21 00:00', - '3.906KB', - '9', - '2015-09-21 00:00', - '5.859KB', - '6', - '2015-09-21 00:00', - '7.813KB', - '10', - '2015-09-21 00:00', - '11.719KB', - '1', - '2015-09-21 03:00', - '0B', - '28', - '2015-09-21 03:00', - '1.953KB', - '39', - '2015-09-21 03:00', - '3.906KB', - '36', - '2015-09-21 03:00', - '5.859KB', - '43', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-21 00:00', '0B', '7'], + ['2015-09-21 00:00', '1.953KB', '9'], + ['2015-09-21 00:00', '3.906KB', '9'], + ['2015-09-21 00:00', '5.859KB', '6'], + ['2015-09-21 00:00', '7.813KB', '10'], + ['2015-09-21 00:00', '11.719KB', '1'], + ['2015-09-21 03:00', '0B', '28'], + ['2015-09-21 03:00', '1.953KB', '39'], + ['2015-09-21 03:00', '3.906KB', '36'], + ['2015-09-21 03:00', '5.859KB', '43'], ]); }); @@ -136,39 +94,18 @@ export default function ({ getService, getPageObjects }) { await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - const data = await PageObjects.visChart.getTableVisData(); - log.debug(data.split('\n')); - expect(data.trim().split('\n')).to.be.eql([ - '03:00', - '0B', - '1', - '03:00', - '1.953KB', - '1', - '03:00', - '3.906KB', - '1', - '03:00', - '5.859KB', - '2', - '03:10', - '0B', - '1', - '03:10', - '5.859KB', - '1', - '03:10', - '7.813KB', - '1', - '03:15', - '0B', - '1', - '03:15', - '1.953KB', - '1', - '03:20', - '1.953KB', - '1', + const data = await PageObjects.visChart.getTableVisContent(); + expect(data).to.be.eql([ + ['03:00', '0B', '1'], + ['03:00', '1.953KB', '1'], + ['03:00', '3.906KB', '1'], + ['03:00', '5.859KB', '2'], + ['03:10', '0B', '1'], + ['03:10', '5.859KB', '1'], + ['03:10', '7.813KB', '1'], + ['03:15', '0B', '1'], + ['03:15', '1.953KB', '1'], + ['03:20', '1.953KB', '1'], ]); }); }); diff --git a/test/functional/apps/visualize/_histogram_request_start.js b/test/functional/apps/visualize/_histogram_request_start.ts similarity index 76% rename from test/functional/apps/visualize/_histogram_request_start.js rename to test/functional/apps/visualize/_histogram_request_start.ts index 8489cffa805da..d51a90c997e66 100644 --- a/test/functional/apps/visualize/_histogram_request_start.js +++ b/test/functional/apps/visualize/_histogram_request_start.ts @@ -18,10 +18,12 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); + const PageObjects = getPageObjects([ 'common', 'visualize', @@ -48,33 +50,32 @@ export default function ({ getService, getPageObjects }) { describe('interval parameter uses autoBounds', function () { it('should use provided value when number of generated buckets is less than histogram:maxBars', async function () { - const providedInterval = 2400000000; + const providedInterval = '2400000000'; log.debug(`Interval = ${providedInterval}`); await PageObjects.visEditor.setInterval(providedInterval, { type: 'numeric' }); await PageObjects.visEditor.clickGo(); + await retry.try(async () => { - const data = await PageObjects.visChart.getTableVisData(); - const dataArray = data.replace(/,/g, '').split('\n'); - expect(dataArray.length).to.eql(20); - const bucketStart = parseInt(dataArray[0], 10); - const bucketEnd = parseInt(dataArray[2], 10); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data.length).to.eql(10); + const bucketStart = parseInt((data[0][0] as string).replace(/,/g, ''), 10); + const bucketEnd = parseInt((data[1][0] as string).replace(/,/g, ''), 10); const actualInterval = bucketEnd - bucketStart; expect(actualInterval).to.eql(providedInterval); }); }); it('should scale value to round number when number of generated buckets is greater than histogram:maxBars', async function () { - const providedInterval = 100; + const providedInterval = '100'; log.debug(`Interval = ${providedInterval}`); await PageObjects.visEditor.setInterval(providedInterval, { type: 'numeric' }); await PageObjects.visEditor.clickGo(); - await PageObjects.common.sleep(1000); //fix this + await PageObjects.common.sleep(1000); // fix this await retry.try(async () => { - const data = await PageObjects.visChart.getTableVisData(); - const dataArray = data.replace(/,/g, '').split('\n'); - expect(dataArray.length).to.eql(20); - const bucketStart = parseInt(dataArray[0], 10); - const bucketEnd = parseInt(dataArray[2], 10); + const data = await PageObjects.visChart.getTableVisContent(); + expect(data.length).to.eql(10); + const bucketStart = parseInt((data[0][0] as string).replace(/,/g, ''), 10); + const bucketEnd = parseInt((data[1][0] as string).replace(/,/g, ''), 10); const actualInterval = bucketEnd - bucketStart; expect(actualInterval).to.eql(1200000000); }); diff --git a/test/functional/apps/visualize/_linked_saved_searches.ts b/test/functional/apps/visualize/_linked_saved_searches.ts index a5a9c47d3d010..e7402de399e41 100644 --- a/test/functional/apps/visualize/_linked_saved_searches.ts +++ b/test/functional/apps/visualize/_linked_saved_searches.ts @@ -52,8 +52,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.clickSavedSearch(savedSearchName); await PageObjects.timePicker.setDefaultAbsoluteRange(); await retry.waitFor('wait for count to equal 9,109', async () => { - const data = await PageObjects.visChart.getTableVisData(); - return data.trim() === '9,109'; + const data = await PageObjects.visChart.getTableVisContent(); + return data[0][0] === '9,109'; }); }); @@ -81,8 +81,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Sep 21, 2015 @ 10:00:00.000' ); await retry.waitFor('wait for count to equal 3,950', async () => { - const data = await PageObjects.visChart.getTableVisData(); - return data.trim() === '3,950'; + const data = await PageObjects.visChart.getTableVisContent(); + return data[0][0] === '3,950'; }); }); @@ -90,16 +90,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await filterBar.addFilter('bytes', 'is between', '100', '3000'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.waitFor('wait for count to equal 707', async () => { - const data = await PageObjects.visChart.getTableVisData(); - return data.trim() === '707'; + const data = await PageObjects.visChart.getTableVisContent(); + return data[0][0] === '707'; }); }); it('should allow unlinking from a linked search', async () => { await PageObjects.visualize.clickUnlinkSavedSearch(); await retry.waitFor('wait for count to equal 707', async () => { - const data = await PageObjects.visChart.getTableVisData(); - return data.trim() === '707'; + const data = await PageObjects.visChart.getTableVisContent(); + return data[0][0] === '707'; }); // The filter on the saved search should now be in the editor expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(true); @@ -109,8 +109,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await filterBar.toggleFilterEnabled('extension.raw'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.waitFor('wait for count to equal 1,293', async () => { - const unfilteredData = await PageObjects.visChart.getTableVisData(); - return unfilteredData.trim() === '1,293'; + const unfilteredData = await PageObjects.visChart.getTableVisContent(); + return unfilteredData[0][0] === '1,293'; }); }); @@ -118,8 +118,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.saveVisualizationExpectSuccess('Unlinked before saved'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.waitFor('wait for count to equal 1,293', async () => { - const data = await PageObjects.visChart.getTableVisData(); - return data.trim() === '1,293'; + const data = await PageObjects.visChart.getTableVisContent(); + return data[0][0] === '1,293'; }); }); }); diff --git a/test/functional/apps/visualize/legacy/_data_table.ts b/test/functional/apps/visualize/legacy/_data_table.ts new file mode 100644 index 0000000000000..cec58e2c717c4 --- /dev/null +++ b/test/functional/apps/visualize/legacy/_data_table.ts @@ -0,0 +1,331 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from 'test/functional/ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects([ + 'visualize', + 'timePicker', + 'visEditor', + 'visChart', + 'legacyDataTableVis', + ]); + + describe('legacy data table visualization', function indexPatternCreation() { + before(async function () { + log.debug('navigateToApp visualize'); + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + log.debug('clickDataTable'); + await PageObjects.visualize.clickDataTable(); + log.debug('clickNewSearch'); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + log.debug('Bucket = Split rows'); + await PageObjects.visEditor.clickBucket('Split rows'); + log.debug('Aggregation = Histogram'); + await PageObjects.visEditor.selectAggregation('Histogram'); + log.debug('Field = bytes'); + await PageObjects.visEditor.selectField('bytes'); + log.debug('Interval = 2000'); + await PageObjects.visEditor.setInterval('2000', { type: 'numeric' }); + await PageObjects.visEditor.clickGo(); + }); + + it('should show percentage columns', async () => { + async function expectValidTableData() { + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['≥ 0B and < 1,000B', '1,351', '64.703%'], + ['≥ 1,000B and < 1.953KB', '737', '35.297%'], + ]); + } + + // load a plain table + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickDataTable(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Range'); + await PageObjects.visEditor.selectField('bytes'); + await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickOptionsTab(); + await PageObjects.visEditor.setSelectByOptionText( + 'datatableVisualizationPercentageCol', + 'Count' + ); + await PageObjects.visEditor.clickGo(); + + await expectValidTableData(); + + // check that it works after selecting a column that's deleted + await PageObjects.visEditor.clickDataTab(); + await PageObjects.visEditor.clickBucket('Metric', 'metrics'); + await PageObjects.visEditor.selectAggregation('Average', 'metrics'); + await PageObjects.visEditor.selectField('bytes', 'metrics'); + await PageObjects.visEditor.removeDimension(1); + await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickOptionsTab(); + + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['≥ 0B and < 1,000B', '344.094B'], + ['≥ 1,000B and < 1.953KB', '1.697KB'], + ]); + }); + + it('should show correct data for a data table with date histogram', async () => { + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickDataTable(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Date Histogram'); + await PageObjects.visEditor.selectField('@timestamp'); + await PageObjects.visEditor.setInterval('Day'); + await PageObjects.visEditor.clickGo(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['2015-09-20', '4,757'], + ['2015-09-21', '4,614'], + ['2015-09-22', '4,633'], + ]); + }); + + describe('otherBucket', () => { + before(async () => { + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickDataTable(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('extension.raw'); + await PageObjects.visEditor.setSize(2); + await PageObjects.visEditor.clickGo(); + + await PageObjects.visEditor.toggleOtherBucket(); + await PageObjects.visEditor.toggleMissingBucket(); + await PageObjects.visEditor.clickGo(); + }); + + it('should show correct data', async () => { + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['jpg', '9,109'], + ['css', '2,159'], + ['Other', '2,736'], + ]); + }); + + it('should apply correct filter', async () => { + await PageObjects.legacyDataTableVis.filterOnTableCell(1, 3); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['png', '1,373'], + ['gif', '918'], + ['Other', '445'], + ]); + }); + }); + + describe('metricsOnAllLevels', () => { + before(async () => { + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickDataTable(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('extension.raw'); + await PageObjects.visEditor.setSize(2); + await PageObjects.visEditor.toggleOpenEditor(2, 'false'); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('geo.dest'); + await PageObjects.visEditor.toggleOpenEditor(3, 'false'); + await PageObjects.visEditor.clickGo(); + }); + + it('should show correct data without showMetricsAtAllLevels', async () => { + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['jpg', 'CN', '1,718'], + ['jpg', 'IN', '1,511'], + ['jpg', 'US', '770'], + ['jpg', 'ID', '314'], + ['jpg', 'PK', '244'], + ['css', 'CN', '422'], + ['css', 'IN', '346'], + ['css', 'US', '189'], + ['css', 'ID', '68'], + ['css', 'BR', '58'], + ]); + }); + + it('should show correct data without showMetricsAtAllLevels even if showPartialRows is selected', async () => { + await PageObjects.visEditor.clickOptionsTab(); + await testSubjects.setCheckbox('showPartialRows', 'check'); + await PageObjects.visEditor.clickGo(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['jpg', 'CN', '1,718'], + ['jpg', 'IN', '1,511'], + ['jpg', 'US', '770'], + ['jpg', 'ID', '314'], + ['jpg', 'PK', '244'], + ['css', 'CN', '422'], + ['css', 'IN', '346'], + ['css', 'US', '189'], + ['css', 'ID', '68'], + ['css', 'BR', '58'], + ]); + }); + + it('should show metrics on each level', async () => { + await PageObjects.visEditor.clickOptionsTab(); + await testSubjects.setCheckbox('showMetricsAtAllLevels', 'check'); + await PageObjects.visEditor.clickGo(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['jpg', '9,109', 'CN', '1,718'], + ['jpg', '9,109', 'IN', '1,511'], + ['jpg', '9,109', 'US', '770'], + ['jpg', '9,109', 'ID', '314'], + ['jpg', '9,109', 'PK', '244'], + ['css', '2,159', 'CN', '422'], + ['css', '2,159', 'IN', '346'], + ['css', '2,159', 'US', '189'], + ['css', '2,159', 'ID', '68'], + ['css', '2,159', 'BR', '58'], + ]); + }); + + it('should show metrics other than count on each level', async () => { + await PageObjects.visEditor.clickDataTab(); + await PageObjects.visEditor.clickBucket('Metric', 'metrics'); + await PageObjects.visEditor.selectAggregation('Average', 'metrics'); + await PageObjects.visEditor.selectField('bytes', 'metrics'); + await PageObjects.visEditor.clickGo(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + ['jpg', '9,109', '5.469KB', 'CN', '1,718', '5.477KB'], + ['jpg', '9,109', '5.469KB', 'IN', '1,511', '5.456KB'], + ['jpg', '9,109', '5.469KB', 'US', '770', '5.371KB'], + ['jpg', '9,109', '5.469KB', 'ID', '314', '5.424KB'], + ['jpg', '9,109', '5.469KB', 'PK', '244', '5.41KB'], + ['css', '2,159', '5.566KB', 'CN', '422', '5.712KB'], + ['css', '2,159', '5.566KB', 'IN', '346', '5.754KB'], + ['css', '2,159', '5.566KB', 'US', '189', '5.333KB'], + ['css', '2,159', '5.566KB', 'ID', '68', '4.82KB'], + ['css', '2,159', '5.566KB', 'BR', '58', '5.915KB'], + ]); + }); + }); + + describe('split tables', () => { + before(async () => { + await PageObjects.visualize.navigateToNewAggBasedVisualization(); + await PageObjects.visualize.clickDataTable(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.visEditor.clickBucket('Split table'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('extension.raw'); + await PageObjects.visEditor.setSize(2); + await PageObjects.visEditor.toggleOpenEditor(2, 'false'); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('geo.dest'); + await PageObjects.visEditor.setSize(3, 3); + await PageObjects.visEditor.toggleOpenEditor(3, 'false'); + await PageObjects.visEditor.clickBucket('Split rows'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('geo.src'); + await PageObjects.visEditor.setSize(3, 4); + await PageObjects.visEditor.toggleOpenEditor(4, 'false'); + await PageObjects.visEditor.clickGo(); + }); + + it('should have a splitted table', async () => { + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + [ + ['CN', 'CN', '330'], + ['CN', 'IN', '274'], + ['CN', 'US', '140'], + ['IN', 'CN', '286'], + ['IN', 'IN', '281'], + ['IN', 'US', '133'], + ['US', 'CN', '135'], + ['US', 'IN', '134'], + ['US', 'US', '52'], + ], + [ + ['CN', 'CN', '90'], + ['CN', 'IN', '84'], + ['CN', 'US', '27'], + ['IN', 'CN', '69'], + ['IN', 'IN', '58'], + ['IN', 'US', '34'], + ['US', 'IN', '36'], + ['US', 'CN', '29'], + ['US', 'US', '13'], + ], + ]); + }); + + it('should show metrics for split bucket when using showMetricsAtAllLevels', async () => { + await PageObjects.visEditor.clickOptionsTab(); + await testSubjects.setCheckbox('showMetricsAtAllLevels', 'check'); + await PageObjects.visEditor.clickGo(); + const data = await PageObjects.legacyDataTableVis.getTableVisContent(); + expect(data).to.be.eql([ + [ + ['CN', '1,718', 'CN', '330'], + ['CN', '1,718', 'IN', '274'], + ['CN', '1,718', 'US', '140'], + ['IN', '1,511', 'CN', '286'], + ['IN', '1,511', 'IN', '281'], + ['IN', '1,511', 'US', '133'], + ['US', '770', 'CN', '135'], + ['US', '770', 'IN', '134'], + ['US', '770', 'US', '52'], + ], + [ + ['CN', '422', 'CN', '90'], + ['CN', '422', 'IN', '84'], + ['CN', '422', 'US', '27'], + ['IN', '346', 'CN', '69'], + ['IN', '346', 'IN', '58'], + ['IN', '346', 'US', '34'], + ['US', '189', 'IN', '36'], + ['US', '189', 'CN', '29'], + ['US', '189', 'US', '13'], + ], + ]); + }); + }); + }); +} diff --git a/test/functional/apps/visualize/legacy/index.ts b/test/functional/apps/visualize/legacy/index.ts new file mode 100644 index 0000000000000..a44da75182dc7 --- /dev/null +++ b/test/functional/apps/visualize/legacy/index.ts @@ -0,0 +1,48 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context.d'; +import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const browser = getService('browser'); + const log = getService('log'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + describe('visualize with legacy visualizations', () => { + before(async () => { + log.debug('Starting visualize legacy before method'); + await browser.setWindowSize(1280, 800); + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('long_window_logstash'); + await esArchiver.load('visualize'); + await kibanaServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', + }); + }); + + describe('legacy data table visualization', function () { + this.tags('ciGroup9'); + + loadTestFile(require.resolve('./_data_table')); + }); + }); +} diff --git a/test/functional/config.legacy.ts b/test/functional/config.legacy.ts new file mode 100644 index 0000000000000..eae099c6809f2 --- /dev/null +++ b/test/functional/config.legacy.ts @@ -0,0 +1,39 @@ +/* + * 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 { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +// eslint-disable-next-line import/no-default-export +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const defaultConfig = await readConfigFile(require.resolve('./config')); + + return { + ...defaultConfig.getAll(), + + testFiles: [require.resolve('./apps/visualize/legacy')], + + kbnTestServer: { + ...defaultConfig.get('kbnTestServer'), + serverArgs: [ + ...defaultConfig.get('kbnTestServer.serverArgs'), + '--vis_type_table.legacyVisEnabled=true', + ], + }, + }; +} diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index d3a8fb73ac3e5..3c0d43100a9fd 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -39,6 +39,7 @@ import { TileMapPageProvider } from './tile_map_page'; import { TagCloudPageProvider } from './tag_cloud_page'; import { VegaChartPageProvider } from './vega_chart_page'; import { SavedObjectsPageProvider } from './management/saved_objects_page'; +import { LegacyDataTableVisProvider } from './legacy/data_table_vis'; export const pageObjects = { common: CommonPageProvider, @@ -52,6 +53,7 @@ export const pageObjects = { newsfeed: NewsfeedPageProvider, settings: SettingsPageProvider, share: SharePageProvider, + legacyDataTableVis: LegacyDataTableVisProvider, login: LoginPageProvider, timelion: TimelionPageProvider, timePicker: TimePickerProvider, diff --git a/test/functional/page_objects/legacy/data_table_vis.ts b/test/functional/page_objects/legacy/data_table_vis.ts new file mode 100644 index 0000000000000..6b437c7dd640d --- /dev/null +++ b/test/functional/page_objects/legacy/data_table_vis.ts @@ -0,0 +1,96 @@ +/* + * 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 { FtrProviderContext } from 'test/functional/ftr_provider_context'; +import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; + +export function LegacyDataTableVisProvider({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + + class LegacyDataTableVis { + /** + * Converts the table data into nested array + * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] + * @param element table + */ + private async getDataFromElement(element: WebElementWrapper): Promise { + const $ = await element.parseDomContent(); + return $('tr') + .toArray() + .map((row) => + $(row) + .find('td') + .toArray() + .map((cell) => + $(cell) + .text() + .replace(/ /g, '') + .trim() + ) + ); + } + + public async getTableVisContent({ stripEmptyRows = true } = {}) { + return await retry.try(async () => { + const container = await testSubjects.find('tableVis'); + const allTables = await testSubjects.findAllDescendant('paginated-table-body', container); + + if (allTables.length === 0) { + return []; + } + + const allData = await Promise.all( + allTables.map(async (t) => { + let data = await this.getDataFromElement(t); + if (stripEmptyRows) { + data = data.filter( + (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) + ); + } + return data; + }) + ); + + if (allTables.length === 1) { + // If there was only one table we return only the data for that table + // This prevents an unnecessary array around that single table, which + // is the case we have in most tests. + return allData[0]; + } + + return allData; + }); + } + + public async filterOnTableCell(column: number, row: number) { + await retry.try(async () => { + const tableVis = await testSubjects.find('tableVis'); + const cell = await tableVis.findByCssSelector( + `tbody tr:nth-child(${row}) td:nth-child(${column})` + ); + await cell.moveMouseTo(); + const filterBtn = await testSubjects.findDescendant('filterForCellValue', cell); + await filterBtn.click(); + }); + } + } + + return new LegacyDataTableVis(); +} diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index b036e8e02fae2..553c8312a59af 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -25,7 +25,7 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr const find = getService('find'); const log = getService('log'); const retry = getService('retry'); - const table = getService('table'); + const dataGrid = getService('dataGrid'); const defaultFindTimeout = config.get('timeouts.find'); const { common } = getPageObjects(['common']); @@ -283,18 +283,6 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr }); } - public async filterOnTableCell(column: string, row: string) { - await retry.try(async () => { - const tableVis = await testSubjects.find('tableVis'); - const cell = await tableVis.findByCssSelector( - `tbody tr:nth-child(${row}) td:nth-child(${column})` - ); - await cell.moveMouseTo(); - const filterBtn = await testSubjects.findDescendant('filterForCellValue', cell); - await filterBtn.click(); - }); - } - public async getMarkdownText() { const markdownContainer = await testSubjects.find('markdownBody'); return markdownContainer.getVisibleText(); @@ -306,44 +294,33 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr return element.getVisibleText(); } - public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { - const tableVis = await testSubjects.find('tableVis'); - const $ = await tableVis.parseDomContent(); - const headers = $('span[ng-bind="::col.title"]') - .toArray() - .map((header: any) => $(header).text()); - const fieldColumnIndex = headers.indexOf(fieldName); - return await find.byCssSelector( - `[data-test-subj="paginated-table-body"] tr:nth-of-type(${rowIndex}) td:nth-of-type(${ - fieldColumnIndex + 1 - }) a` - ); - } + // Table visualization - /** - * If you are writing new tests, you should rather look into getTableVisContent method instead. - * @deprecated Use getTableVisContent instead. - */ - public async getTableVisData() { - return await testSubjects.getVisibleText('paginated-table-body'); + public async getTableVisNoResult() { + return await testSubjects.find('tbvChartContainer>visNoResult'); } /** * This function returns the text displayed in the Table Vis header */ public async getTableVisHeader() { - return await testSubjects.getVisibleText('paginated-table-header'); + return await testSubjects.getVisibleText('dataGridHeader'); + } + + public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { + const headers = await dataGrid.getHeaders(); + const fieldColumnIndex = headers.indexOf(fieldName); + const cell = await dataGrid.getCellElement(rowIndex, fieldColumnIndex + 1); + return await cell.findByTagName('a'); } /** - * This function is the newer function to retrieve data from within a table visualization. - * It uses a better return format, than the old getTableVisData, by properly splitting - * cell values into arrays. Please use this function for newer tests. + * Function to retrieve data from within a table visualization. */ public async getTableVisContent({ stripEmptyRows = true } = {}) { return await retry.try(async () => { - const container = await testSubjects.find('tableVis'); - const allTables = await testSubjects.findAllDescendant('paginated-table-body', container); + const container = await testSubjects.find('tbvChart'); + const allTables = await testSubjects.findAllDescendant('dataGridWrapper', container); if (allTables.length === 0) { return []; @@ -351,7 +328,7 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr const allData = await Promise.all( allTables.map(async (t) => { - let data = await table.getDataFromElement(t); + let data = await dataGrid.getDataFromElement(t, 'tbvChartCellContent'); if (stripEmptyRows) { data = data.filter( (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) @@ -372,6 +349,18 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr }); } + public async filterOnTableCell(column: number, row: number) { + await retry.try(async () => { + const cell = await dataGrid.getCellElement(row, column); + await cell.moveMouseTo(); + const filterBtn = await testSubjects.findDescendant( + 'tbvChartCell__filterForCellValue', + cell + ); + await filterBtn.click(); + }); + } + public async getMetric() { const elements = await find.allByCssSelector( '[data-test-subj="visualizationLoader"] .mtrVis__container' diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index cdbbeb9188732..f05cd35c5cb03 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -388,7 +388,7 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP } } - public async setSize(newValue: string, aggId: string) { + public async setSize(newValue: number, aggId?: number) { const dataTestSubj = aggId ? `visEditorAggAccordion${aggId} > sizeParamEditor` : 'sizeParamEditor'; diff --git a/test/functional/services/dashboard/expectations.ts b/test/functional/services/dashboard/expectations.ts index 77a441a772d84..62e541e3baa4c 100644 --- a/test/functional/services/dashboard/expectations.ts +++ b/test/functional/services/dashboard/expectations.ts @@ -27,7 +27,7 @@ export function DashboardExpectProvider({ getService, getPageObjects }: FtrProvi const testSubjects = getService('testSubjects'); const find = getService('find'); const filterBar = getService('filterBar'); - const PageObjects = getPageObjects(['dashboard', 'visualize']); + const PageObjects = getPageObjects(['dashboard', 'visualize', 'visChart']); const findTimeout = 2500; return new (class DashboardExpect { @@ -233,14 +233,18 @@ export function DashboardExpectProvider({ getService, getPageObjects }: FtrProvi async dataTableRowCount(expectedCount: number) { log.debug(`DashboardExpect.dataTableRowCount(${expectedCount})`); await retry.try(async () => { - const dataTableRows = await find.allByCssSelector( - '[data-test-subj="paginated-table-body"] [data-cell-content]', - findTimeout - ); + const dataTableRows = await PageObjects.visChart.getTableVisContent(); expect(dataTableRows.length).to.be(expectedCount); }); } + async dataTableNoResult(expectedCount: number) { + log.debug(`DashboardExpect.dataTableNoResult`); + await retry.try(async () => { + await PageObjects.visChart.getTableVisNoResult(); + }); + } + async seriesElementCount(expectedCount: number) { log.debug(`DashboardExpect.seriesElementCount(${expectedCount})`); await retry.try(async () => { diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 40157caab5756..209e30d23ca3c 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -18,6 +18,7 @@ */ import { FtrProviderContext } from '../ftr_provider_context'; +import { WebElementWrapper } from './lib/web_element_wrapper'; interface TabbedGridData { columns: string[]; @@ -26,6 +27,7 @@ interface TabbedGridData { export function DataGridProvider({ getService }: FtrProviderContext) { const find = getService('find'); + const testSubjects = getService('testSubjects'); class DataGrid { async getDataGridTableData(): Promise { @@ -49,6 +51,58 @@ export function DataGridProvider({ getService }: FtrProviderContext) { rows, }; } + + /** + * Converts the data grid data into nested array + * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] + * @param element table + */ + public async getDataFromElement( + element: WebElementWrapper, + cellDataTestSubj: string + ): Promise { + const $ = await element.parseDomContent(); + return $('[data-test-subj="dataGridRow"]') + .toArray() + .map((row) => + $(row) + .findTestSubjects('dataGridRowCell') + .toArray() + .map((cell) => + $(cell) + .findTestSubject(cellDataTestSubj) + .text() + .replace(/ /g, '') + .trim() + ) + ); + } + + /** + * Returns an array of data grid headers names + */ + public async getHeaders() { + const header = await testSubjects.find('dataGridWrapper > dataGridHeader'); + const $ = await header.parseDomContent(); + return $('.euiDataGridHeaderCell__content') + .toArray() + .map((cell) => $(cell).text()); + } + + /** + * Returns a grid cell element by row & column indexes. + * The row offset equals 1 since the first row of data grid is the header row. + * @param rowIndex data row index starting from 1 (1 means 1st row) + * @param columnIndex column index starting from 1 (1 means 1st column) + */ + public async getCellElement(rowIndex: number, columnIndex: number) { + return await find.byCssSelector( + `[data-test-subj="dataGridWrapper"] [data-test-subj="dataGridRow"]:nth-of-type(${ + rowIndex + 1 + }) + [data-test-subj="dataGridRowCell"]:nth-of-type(${columnIndex})` + ); + } } return new DataGrid(); diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index 2c71fd8ef8f55..64a8ac751acd9 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -46,7 +46,6 @@ import { ManagementMenuProvider } from './management'; import { QueryBarProvider } from './query_bar'; import { RemoteProvider } from './remote'; import { RenderableProvider } from './renderable'; -import { TableProvider } from './table'; import { ToastsProvider } from './toasts'; import { DataGridProvider } from './data_grid'; import { @@ -82,7 +81,6 @@ export const services = { dataGrid: DataGridProvider, embedding: EmbeddingProvider, renderable: RenderableProvider, - table: TableProvider, browser: BrowserProvider, pieChart: PieChartProvider, inspector: InspectorProvider, diff --git a/test/functional/services/table.ts b/test/functional/services/table.ts deleted file mode 100644 index 8dbed2e5d250b..0000000000000 --- a/test/functional/services/table.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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 { FtrProviderContext } from '../ftr_provider_context'; -import { WebElementWrapper } from './lib/web_element_wrapper'; - -export function TableProvider({ getService }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - - class Table { - /** - * Finds table and returns data in the nested array format - * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] - * @param dataTestSubj data-test-subj selector - */ - - public async getDataFromTestSubj(dataTestSubj: string): Promise { - const table = await testSubjects.find(dataTestSubj); - return await this.getDataFromElement(table); - } - - /** - * Converts the table data into nested array - * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] - * @param element table - */ - public async getDataFromElement(element: WebElementWrapper): Promise { - const $ = await element.parseDomContent(); - return $('tr') - .toArray() - .map((row) => - $(row) - .find('td') - .toArray() - .map((cell) => - $(cell) - .text() - .replace(/ /g, '') - .trim() - ) - ); - } - } - - return new Table(); -} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bcff08f892299..7040e94fef984 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3693,6 +3693,8 @@ "visTypeTable.aggTable.rawLabel": "生", "visTypeTable.directives.tableCellFilter.filterForValueTooltip": "値でフィルタリング", "visTypeTable.directives.tableCellFilter.filterOutValueTooltip": "値を除外", + "visTypeTable.tableCellFilter.filterForValueText": "値でフィルタリング", + "visTypeTable.tableCellFilter.filterOutValueText": "値を除外", "visTypeTable.function.help": "表ビジュアライゼーション", "visTypeTable.params.defaultPercentageCol": "非表示", "visTypeTable.params.PercentageColLabel": "パーセンテージ列", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 99ab1db30628f..c0fc0540c2cd7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3694,6 +3694,8 @@ "visTypeTable.aggTable.rawLabel": "原始", "visTypeTable.directives.tableCellFilter.filterForValueTooltip": "筛留值", "visTypeTable.directives.tableCellFilter.filterOutValueTooltip": "筛除值", + "visTypeTable.tableCellFilter.filterForValueText": "筛留值", + "visTypeTable.tableCellFilter.filterOutValueText": "筛除值", "visTypeTable.function.help": "表可视化", "visTypeTable.params.defaultPercentageCol": "不显示", "visTypeTable.params.PercentageColLabel": "百分比列",