From e9e9f46b2a174001e44f94fa00622d1d86d976d9 Mon Sep 17 00:00:00 2001 From: Anan Zhuang Date: Tue, 7 Feb 2023 18:27:05 +0000 Subject: [PATCH] [Table Vis] move format table, consolidate types and add unit tests Currently, table data is formatted by a until function convertToFormattedData in TableVisComponent. In this PR, we moved the formatting data process to table_vis_response_handler.ts to combine with other data process logics. In this way, component render and data handling logics are completely isolated. This PR also solidate some types. Issue Resolved: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3395 https://github.com/opensearch-project/OpenSearch-Dashboards/issues/2856 Signed-off-by: Anan Zhuang --- CHANGELOG.md | 1 + .../public/components/table_vis_app.scss | 16 +- .../public/components/table_vis_app.test.tsx | 98 ++++++++ .../public/components/table_vis_app.tsx | 17 +- .../public/components/table_vis_cell.test.tsx | 65 +++++ .../public/components/table_vis_cell.tsx | 38 +++ .../components/table_vis_component.test.tsx | 234 ++++++++++++++++++ .../public/components/table_vis_component.tsx | 117 ++++----- .../table_vis_component_group.test.tsx | 67 +++++ .../components/table_vis_component_group.tsx | 7 +- .../components/table_vis_grid_columns.tsx | 21 +- .../vis_type_table/public/table_vis_fn.ts | 4 +- .../public/table_vis_renderer.test.tsx | 78 ++++++ .../public/table_vis_response_handler.test.ts | 157 ++++++++++++ .../public/table_vis_response_handler.ts | 60 ++--- src/plugins/vis_type_table/public/types.ts | 7 - .../add_percentage_col.test.ts.snap | 170 +++++++++++++ .../public/utils/add_percentage_col.test.ts | 76 ++++++ .../public/utils/add_percentage_col.ts | 76 ++++++ .../public/utils/convert_to_csv_data.test.ts | 76 ++++++ .../public/utils/convert_to_csv_data.ts | 2 +- .../utils/convert_to_formatted_data.test.ts | 136 ++++++++++ .../public/utils/convert_to_formatted_data.ts | 69 ++---- .../public/utils/get_table_ui_state.test.ts | 66 +++++ .../public/utils/get_table_ui_state.ts | 40 +++ .../vis_type_table/public/utils/index.ts | 2 + .../public/utils/use_pagination.test.ts | 101 ++++++++ .../public/utils/use_pagination.ts | 23 +- 28 files changed, 1627 insertions(+), 197 deletions(-) create mode 100644 src/plugins/vis_type_table/public/components/table_vis_app.test.tsx create mode 100644 src/plugins/vis_type_table/public/components/table_vis_cell.test.tsx create mode 100644 src/plugins/vis_type_table/public/components/table_vis_cell.tsx create mode 100644 src/plugins/vis_type_table/public/components/table_vis_component.test.tsx create mode 100644 src/plugins/vis_type_table/public/components/table_vis_component_group.test.tsx create mode 100644 src/plugins/vis_type_table/public/table_vis_renderer.test.tsx create mode 100644 src/plugins/vis_type_table/public/table_vis_response_handler.test.ts create mode 100644 src/plugins/vis_type_table/public/utils/__snapshots__/add_percentage_col.test.ts.snap create mode 100644 src/plugins/vis_type_table/public/utils/add_percentage_col.test.ts create mode 100644 src/plugins/vis_type_table/public/utils/add_percentage_col.ts create mode 100644 src/plugins/vis_type_table/public/utils/convert_to_csv_data.test.ts create mode 100644 src/plugins/vis_type_table/public/utils/convert_to_formatted_data.test.ts create mode 100644 src/plugins/vis_type_table/public/utils/get_table_ui_state.test.ts create mode 100644 src/plugins/vis_type_table/public/utils/get_table_ui_state.ts create mode 100644 src/plugins/vis_type_table/public/utils/use_pagination.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ad516b6c049f..cc7ad4d9350e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple DataSource] Allow create and distinguish index pattern with same name but from different datasources ([#3571](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3571)) - [Multiple DataSource] Integrate multiple datasource with dev tool console ([#3754](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3754)) - Add satisfaction survey link to help menu ([#3676] (https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3676)) +- [Table Visualization] Move format table, consolidate types and add unit tests ([#3397](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3397)) ### 🐛 Bug Fixes diff --git a/src/plugins/vis_type_table/public/components/table_vis_app.scss b/src/plugins/vis_type_table/public/components/table_vis_app.scss index 876847667418..aafcd40e7382 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_app.scss +++ b/src/plugins/vis_type_table/public/components/table_vis_app.scss @@ -1,21 +1,29 @@ +// Container for the Table Visualization component .visTable { display: flex; flex-direction: column; flex: 1 0 0; overflow: auto; + + @include euiScrollBar; } +// Group container for table visualization components .visTable__group { padding: $euiSizeS; margin-bottom: $euiSizeL; + display: flex; + flex-direction: column; + flex: 0 0 auto; +} - > h3 { - text-align: center; - } +// Style for table component title +.visTable__component__title { + text-align: center; } +// Modifier for visTables__group when displayed in columns .visTable__groupInColumns { - display: flex; flex-direction: row; align-items: flex-start; } diff --git a/src/plugins/vis_type_table/public/components/table_vis_app.test.tsx b/src/plugins/vis_type_table/public/components/table_vis_app.test.tsx new file mode 100644 index 000000000000..37cb753765f8 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_app.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { coreMock } from '../../../../core/public/mocks'; +import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { TableVisApp } from './table_vis_app'; +import { TableVisConfig } from '../types'; +import { TableVisData } from '../table_vis_response_handler'; + +jest.mock('./table_vis_component_group', () => ({ + TableVisComponentGroup: () => ( +
TableVisComponentGroup
+ ), +})); + +jest.mock('./table_vis_component', () => ({ + TableVisComponent: () =>
TableVisComponent
, +})); + +describe('TableVisApp', () => { + const serviceMock = coreMock.createStart(); + const handlersMock = ({ + done: jest.fn(), + uiState: { + get: jest.fn((key) => { + switch (key) { + case 'vis.sortColumn': + return {}; + case 'vis.columnsWidth': + return []; + default: + return undefined; + } + }), + set: jest.fn(), + }, + event: 'event', + } as unknown) as IInterpreterRenderHandlers; + const visConfigMock = ({} as unknown) as TableVisConfig; + + it('should render TableVisComponent if no split table', () => { + const visDataMock = { + table: { + columns: [], + rows: [], + formattedColumns: [], + }, + tableGroups: [], + } as TableVisData; + const { getByTestId } = render( + + ); + expect(getByTestId('TableVisComponent')).toBeInTheDocument(); + }); + + it('should render TableVisComponentGroup component if split direction is column', () => { + const visDataMock = { + tableGroups: [], + direction: 'column', + } as TableVisData; + const { container, getByTestId } = render( + + ); + expect(container.outerHTML.includes('visTable visTable__groupInColumns')).toBe(true); + expect(getByTestId('TableVisComponentGroup')).toBeInTheDocument(); + }); + + it('should render TableVisComponentGroup component if split direction is row', () => { + const visDataMock = { + tableGroups: [], + direction: 'row', + } as TableVisData; + const { container, getByTestId } = render( + + ); + expect(container.outerHTML.includes('visTable')).toBe(true); + expect(getByTestId('TableVisComponentGroup')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/vis_type_table/public/components/table_vis_app.tsx b/src/plugins/vis_type_table/public/components/table_vis_app.tsx index af10500a1a92..81f4d775f1e5 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_app.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_app.tsx @@ -4,20 +4,22 @@ */ import './table_vis_app.scss'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import classNames from 'classnames'; import { CoreStart } from 'opensearch-dashboards/public'; import { I18nProvider } from '@osd/i18n/react'; import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { PersistedState } from '../../../visualizations/public'; import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; -import { TableContext } from '../table_vis_response_handler'; -import { TableVisConfig, ColumnSort, ColumnWidth, TableUiState } from '../types'; +import { TableVisData } from '../table_vis_response_handler'; +import { TableVisConfig } from '../types'; import { TableVisComponent } from './table_vis_component'; import { TableVisComponentGroup } from './table_vis_component_group'; +import { getTableUIState, TableUiState } from '../utils'; interface TableVisAppProps { services: CoreStart; - visData: TableContext; + visData: TableVisData; visConfig: TableVisConfig; handlers: IInterpreterRenderHandlers; } @@ -38,12 +40,7 @@ export const TableVisApp = ({ visTable__groupInColumns: direction === 'column', }); - // TODO: remove duplicate sort and width state - // Issue: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/2704#issuecomment-1299380818 - const [sort, setSort] = useState({ colIndex: null, direction: null }); - const [width, setWidth] = useState([]); - - const tableUiState: TableUiState = { sort, setSort, width, setWidth }; + const tableUiState: TableUiState = getTableUIState(handlers.uiState as PersistedState); return ( diff --git a/src/plugins/vis_type_table/public/components/table_vis_cell.test.tsx b/src/plugins/vis_type_table/public/components/table_vis_cell.test.tsx new file mode 100644 index 000000000000..9b1b1c02ac40 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_cell.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { OpenSearchDashboardsDatatableRow } from 'src/plugins/expressions'; +import { FormattedColumn } from '../types'; +import { getTableVisCellValue } from './table_vis_cell'; +import { FieldFormat } from 'src/plugins/data/public'; + +class MockFieldFormat extends FieldFormat { + convert = jest.fn(); +} + +describe('getTableVisCellValue', () => { + const mockFormatter = new MockFieldFormat(); + + const columns: FormattedColumn[] = [ + { + id: 'testId', + title: 'Test Column', + formatter: mockFormatter, + filterable: true, + }, + ]; + + const sortedRows: OpenSearchDashboardsDatatableRow[] = [ + { + testId: 'Test Value 1', + }, + { + testId: 'Test Value 2', + }, + ]; + + const TableCell = ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => { + const getCellValue = getTableVisCellValue(sortedRows, columns); + return getCellValue({ rowIndex, columnId }); + }; + + beforeEach(() => { + mockFormatter.convert.mockClear(); + }); + + test('should render cell value with correct formatting', () => { + mockFormatter.convert.mockReturnValueOnce('Test Value 1'); + const { getByText } = render(); + expect(mockFormatter.convert).toHaveBeenCalledWith('Test Value 1', 'html'); + expect(getByText('Test Value 1')).toBeInTheDocument(); + expect(getByText('Test Value 1').closest('strong')).toBeInTheDocument(); + }); + + test('should return null when rowIndex is out of bounds', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('should return null when no matching columnId is found', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); 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 000000000000..30c0877df701 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_cell.tsx @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import dompurify from 'dompurify'; + +import { OpenSearchDashboardsDatatableRow } from 'src/plugins/expressions'; +import { FormattedColumn } from '../types'; + +export const getTableVisCellValue = ( + sortedRows: OpenSearchDashboardsDatatableRow[], + columns: FormattedColumn[] +) => ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => { + if (rowIndex < 0 || rowIndex >= sortedRows.length) { + return null; + } + const row = sortedRows[rowIndex]; + if (!row || !row.hasOwnProperty(columnId)) { + return null; + } + const rawContent = row[columnId]; + const colIndex = columns.findIndex((col) => col.id === columnId); + const htmlContent = columns[colIndex].formatter.convert(rawContent, 'html'); + const formattedContent = ( + /* + * Justification for dangerouslySetInnerHTML: + * This is one of the visualizations which makes use of the HTML field formatters. + * Since these formatters produce raw HTML, this visualization needs to be able to render them as-is, relying + * on the field formatter to only produce safe HTML. + * `htmlContent` is created by converting raw data via HTML field formatter, so we need to make sure this value never contains + * any unsafe HTML (e.g. by bypassing the field formatter). + */ +
// eslint-disable-line react/no-danger + ); + return formattedContent || null; +}; diff --git a/src/plugins/vis_type_table/public/components/table_vis_component.test.tsx b/src/plugins/vis_type_table/public/components/table_vis_component.test.tsx new file mode 100644 index 000000000000..6e2d0090aa36 --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_component.test.tsx @@ -0,0 +1,234 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { TableVisConfig, ColumnSort } from '../types'; +import { TableVisComponent } from './table_vis_component'; +import { FormattedColumn } from '../types'; +import { FormattedTableContext } from '../table_vis_response_handler'; +import { getTableVisCellValue } from './table_vis_cell'; +import { getDataGridColumns } from './table_vis_grid_columns'; +import { EuiDataGridColumn } from '@elastic/eui'; + +jest.mock('./table_vis_cell', () => ({ + getTableVisCellValue: jest.fn(() => () => {}), +})); + +const mockGetDataGridColumns = jest.fn(() => []); +jest.mock('./table_vis_grid_columns', () => ({ + getDataGridColumns: jest.fn(() => mockGetDataGridColumns()), +})); + +const table = { + formattedColumns: [ + { + id: 'col-0-2', + title: 'name.keyword: Descending', + formatter: {}, + filterable: true, + }, + { + id: 'col-1-1', + title: 'Count', + formatter: {}, + filterable: false, + sumTotal: 5, + formattedTotal: 5, + total: 5, + }, + ] as FormattedColumn[], + rows: [ + { 'col-0-2': 'Alice', 'col-1-1': 3 }, + { 'col-0-2': 'Anthony', 'col-1-1': 1 }, + { 'col-0-2': 'Timmy', 'col-1-1': 1 }, + ], + columns: [ + { id: 'col-0-2', name: 'Name' }, + { id: 'col-1-1', name: 'Count' }, + ], +} as FormattedTableContext; + +const visConfig = { + buckets: [ + { + accessor: 0, + aggType: 'terms', + format: { + id: 'terms', + params: { + id: 'number', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + parsedUrl: { + basePath: '/arf', + origin: '', + pathname: '/arf/app/home', + }, + }, + }, + label: 'age: Descending', + params: {}, + }, + ], + metrics: [ + { + accessor: 1, + aggType: 'count', + format: { + id: 'number', + }, + label: 'Count', + params: {}, + }, + ], + perPage: 10, + percentageCol: '', + showMetricsAtAllLevels: false, + showPartialRows: false, + showTotal: false, + title: '', + totalFunc: 'sum', +} as TableVisConfig; + +const uiState = { + sort: {} as ColumnSort, + setSort: jest.fn(), + colWidth: [], + setWidth: jest.fn(), +}; + +describe('TableVisComponent', function () { + const props = { + title: '', + table, + visConfig, + event: jest.fn(), + uiState, + }; + + const dataGridColumnsValue = [ + { + id: 'col-0-2', + display: 'name.keyword: Descending', + displayAsText: 'name.keyword: Descending', + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: {}, + showSortDesc: {}, + }, + cellActions: expect.any(Function), + }, + { + id: 'col-1-1', + display: 'Count', + displayAsText: 'Count', + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: {}, + showSortDesc: {}, + }, + cellActions: undefined, + }, + ] as EuiDataGridColumn[]; + + it('should render data grid', () => { + const comp = shallow(); + expect(comp.find('EuiDataGrid')).toHaveLength(1); + }); + + it('should render title when provided', () => { + const compWithTitle = shallow(); + const titleElement = compWithTitle.find('EuiTitle'); + expect(titleElement).toHaveLength(1); + expect(titleElement.find('h3').text()).toEqual('Test Title'); + }); + + it('should not render title when not provided', () => { + const compWithoutTitle = shallow(); + const titleElement = compWithoutTitle.find('EuiTitle'); + expect(titleElement).toHaveLength(0); + }); + + it('should set sort if sort column', () => { + mockGetDataGridColumns.mockReturnValueOnce(dataGridColumnsValue); + const comp = shallow(); + const { onSort } = comp.find('EuiDataGrid').prop('sorting') as any; + onSort([]); + expect(props.uiState.setSort).toHaveBeenCalledWith([]); + onSort([{ id: 'col-0-2', direction: 'asc' }]); + expect(props.uiState.setSort).toHaveBeenCalledWith({ colIndex: 0, direction: 'asc' }); + onSort([ + { id: 'col-0-2', direction: 'asc' }, + { id: 'col-1-1', direction: 'desc' }, + ]); + expect(props.uiState.setSort).toHaveBeenCalledWith({ colIndex: 1, direction: 'desc' }); + }); + + it('should set width if adjust column width', () => { + const uiStateProps = { + ...props.uiState, + width: [ + { colIndex: 0, width: 12 }, + { colIndex: 1, width: 8 }, + ], + }; + const comp = shallow(); + const onColumnResize = comp.find('EuiDataGrid').prop('onColumnResize') as any; + onColumnResize({ columnId: 'col-0-2', width: 18 }); + expect(props.uiState.setWidth).toHaveBeenCalledWith({ colIndex: 0, width: 18 }); + const updatedComp = shallow(); + const onColumnResizeUpdate = updatedComp.find('EuiDataGrid').prop('onColumnResize') as any; + onColumnResizeUpdate({ columnId: 'col-0-2', width: 18 }); + expect(props.uiState.setWidth).toHaveBeenCalledWith({ colIndex: 0, width: 18 }); + }); + + it('should create sortedRows and pass to getTableVisCellValue', () => { + const uiStateProps = { + ...props.uiState, + sort: { colIndex: 1, direction: 'asc' } as ColumnSort, + }; + const sortedRows = [ + { 'col-0-2': 'Anthony', 'col-1-1': 1 }, + { 'col-0-2': 'Timmy', 'col-1-1': 1 }, + { 'col-0-2': 'Alice', 'col-1-1': 3 }, + ]; + mockGetDataGridColumns.mockReturnValueOnce(dataGridColumnsValue); + shallow(); + expect(getTableVisCellValue).toHaveBeenCalledWith(sortedRows, table.formattedColumns); + expect(getDataGridColumns).toHaveBeenCalledWith(table, props.event, props.uiState.colWidth); + }); + + it('should return formattedTotal from footerCellValue', () => { + let comp = shallow(); + let renderFooterCellValue = comp.find('EuiDataGrid').prop('renderFooterCellValue') as any; + expect(renderFooterCellValue).toEqual(undefined); + comp = shallow(); + renderFooterCellValue = comp.find('EuiDataGrid').prop('renderFooterCellValue'); + expect(renderFooterCellValue({ columnId: 'col-1-1' })).toEqual(5); + expect(renderFooterCellValue({ columnId: 'col-0-2' })).toEqual(null); + }); + + it('should apply pagination correctly', () => { + const comp = shallow(); + const paginationProps = comp.find('EuiDataGrid').prop('pagination'); + expect(paginationProps).toMatchObject({ + pageIndex: 0, + pageSize: 3, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + }); + }); + + it('should not call renderFooterCellValue when showTotal is false', () => { + const comp = shallow(); + const renderFooterCellValue = comp.find('EuiDataGrid').prop('renderFooterCellValue'); + expect(renderFooterCellValue).toBeUndefined(); + }); +}); diff --git a/src/plugins/vis_type_table/public/components/table_vis_component.tsx b/src/plugins/vis_type_table/public/components/table_vis_component.tsx index 4576e3420e22..1b16ec170a84 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_component.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_component.tsx @@ -5,20 +5,20 @@ import React, { useCallback, useMemo } from 'react'; import { orderBy } from 'lodash'; -import dompurify from 'dompurify'; import { EuiDataGridProps, EuiDataGrid, EuiDataGridSorting, EuiTitle } from '@elastic/eui'; import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; -import { Table } from '../table_vis_response_handler'; -import { TableVisConfig, ColumnWidth, ColumnSort, TableUiState } from '../types'; +import { FormattedTableContext } from '../table_vis_response_handler'; +import { TableVisConfig, ColumnSort } from '../types'; import { getDataGridColumns } from './table_vis_grid_columns'; +import { getTableVisCellValue } from './table_vis_cell'; import { usePagination } from '../utils'; -import { convertToFormattedData } from '../utils/convert_to_formatted_data'; import { TableVisControl } from './table_vis_control'; +import { TableUiState } from '../utils'; interface TableVisComponentProps { title?: string; - table: Table; + table: FormattedTableContext; visConfig: TableVisConfig; event: IInterpreterRenderHandlers['event']; uiState: TableUiState; @@ -29,52 +29,44 @@ export const TableVisComponent = ({ table, visConfig, event, - uiState, + uiState: { sort, setSort, colWidth, setWidth }, }: TableVisComponentProps) => { - const { formattedRows: rows, formattedColumns: columns } = convertToFormattedData( - table, - visConfig - ); + const { rows, formattedColumns } = table; const pagination = usePagination(visConfig, rows.length); const sortedRows = useMemo(() => { - return uiState.sort.colIndex !== null && - columns[uiState.sort.colIndex].id && - uiState.sort.direction - ? orderBy(rows, columns[uiState.sort.colIndex].id, uiState.sort.direction) - : rows; - }, [columns, rows, uiState]); + const sortColumnId = + sort.colIndex !== null && sort.colIndex !== undefined + ? formattedColumns[sort.colIndex]?.id + : undefined; + + if (sortColumnId && sort.direction) { + return orderBy(rows, sortColumnId, sort.direction); + } else { + return rows; + } + }, [formattedColumns, rows, sort]); - const renderCellValue = useMemo(() => { - return (({ rowIndex, columnId }) => { - const rawContent = sortedRows[rowIndex][columnId]; - const colIndex = columns.findIndex((col) => col.id === columnId); - const htmlContent = columns[colIndex].formatter.convert(rawContent, 'html'); - const formattedContent = ( - /* - * Justification for dangerouslySetInnerHTML: - * This is one of the visualizations which makes use of the HTML field formatters. - * Since these formatters produce raw HTML, this visualization needs to be able to render them as-is, relying - * on the field formatter to only produce safe HTML. - * `htmlContent` is created by converting raw data via HTML field formatter, so we need to make sure this value never contains - * any unsafe HTML (e.g. by bypassing the field formatter). - */ -
// eslint-disable-line react/no-danger - ); - return sortedRows.hasOwnProperty(rowIndex) ? formattedContent || null : null; - }) as EuiDataGridProps['renderCellValue']; - }, [sortedRows, columns]); + const renderCellValue = useMemo(() => getTableVisCellValue(sortedRows, formattedColumns), [ + sortedRows, + formattedColumns, + ]); - const dataGridColumns = getDataGridColumns(sortedRows, columns, table, event, uiState.width); + const dataGridColumns = getDataGridColumns(table, event, colWidth); const sortedColumns = useMemo(() => { - return uiState.sort.colIndex !== null && - dataGridColumns[uiState.sort.colIndex].id && - uiState.sort.direction - ? [{ id: dataGridColumns[uiState.sort.colIndex].id, direction: uiState.sort.direction }] - : []; - }, [dataGridColumns, uiState]); + if ( + sort.colIndex !== null && + sort.colIndex !== undefined && + dataGridColumns[sort.colIndex].id && + sort.direction + ) { + return [{ id: dataGridColumns[sort.colIndex].id, direction: sort.direction }]; + } else { + return []; + } + }, [dataGridColumns, sort]); const onSort = useCallback( (sortingCols: EuiDataGridSorting['columns'] | []) => { @@ -85,47 +77,34 @@ export const TableVisComponent = ({ colIndex: dataGridColumns.findIndex((col) => col.id === nextSortValue?.id), direction: nextSortValue.direction, } - : { - colIndex: null, - direction: null, - }; - uiState.setSort(nextSort); + : []; + setSort(nextSort); return nextSort; }, - [dataGridColumns, uiState] + [dataGridColumns, setSort] ); const onColumnResize: EuiDataGridProps['onColumnResize'] = useCallback( - ({ columnId, width }) => { - const curWidth: ColumnWidth[] = uiState.width; - const nextWidth = [...curWidth]; - const nextColIndex = columns.findIndex((col) => col.id === columnId); - const curColIndex = curWidth.findIndex((col) => col.colIndex === nextColIndex); - const nextColWidth = { colIndex: nextColIndex, width }; - - // if updated column index is not found, then add it to nextWidth - // else reset it in nextWidth - if (curColIndex < 0) nextWidth.push(nextColWidth); - else nextWidth[curColIndex] = nextColWidth; - - // update uiState.width - uiState.setWidth(nextWidth); + ({ columnId, width }: { columnId: string; width: number }) => { + const colIndex = formattedColumns.findIndex((col) => col.id === columnId); + // update width in uiState + setWidth({ colIndex, width }); }, - [columns, uiState] + [formattedColumns, setWidth] ); const ariaLabel = title || visConfig.title || 'tableVis'; const footerCellValue = visConfig.showTotal ? ({ columnId }: { columnId: any }) => { - return columns.find((col) => col.id === columnId)?.formattedTotal || null; + return formattedColumns.find((col) => col.id === columnId)?.formattedTotal || null; } : undefined; return ( <> {title && ( - +

{title}

)} @@ -133,7 +112,7 @@ export const TableVisComponent = ({ aria-label={ariaLabel} columns={dataGridColumns} columnVisibility={{ - visibleColumns: columns.map(({ id }) => id), + visibleColumns: formattedColumns.map(({ id }) => id), setVisibleColumns: () => {}, }} rowCount={rows.length} @@ -153,7 +132,11 @@ export const TableVisComponent = ({ showFullScreenSelector: false, showStyleSelector: false, additionalControls: ( - + ), }} /> diff --git a/src/plugins/vis_type_table/public/components/table_vis_component_group.test.tsx b/src/plugins/vis_type_table/public/components/table_vis_component_group.test.tsx new file mode 100644 index 000000000000..570c8c0b853b --- /dev/null +++ b/src/plugins/vis_type_table/public/components/table_vis_component_group.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TableVisComponentGroup } from './table_vis_component_group'; +import { TableVisConfig, ColumnSort } from '../types'; +import { Table, TableGroup } from '../table_vis_response_handler'; + +jest.mock('./table_vis_component', () => ({ + TableVisComponent: () =>
TableVisComponent
, +})); + +const table1 = { + table: { + columns: [], + rows: [], + formattedColumns: [], + } as Table, + title: '', +} as TableGroup; + +const table2 = { + table: { + columns: [], + rows: [], + formattedColumns: [], + } as Table, + title: '', +} as TableGroup; + +const tableUiStateMock = { + sort: { colIndex: undefined, direction: undefined } as ColumnSort, + setSort: jest.fn(), + width: [], + setWidth: jest.fn(), +}; + +describe('TableVisApp', () => { + it('should not render table or table group components if no table', () => { + const { container, queryAllByText } = render( + + ); + expect(queryAllByText('TableVisComponent')).toHaveLength(0); + expect(container.outerHTML.includes('visTable__group')).toBe(false); + }); + + it('should render table component 2 times', () => { + const { container, queryAllByText } = render( + + ); + expect(queryAllByText('TableVisComponent')).toHaveLength(2); + expect(container.outerHTML.includes('visTable__group')).toBe(true); + }); +}); diff --git a/src/plugins/vis_type_table/public/components/table_vis_component_group.tsx b/src/plugins/vis_type_table/public/components/table_vis_component_group.tsx index 633b9d2230bd..af8fd8048cbc 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_component_group.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_component_group.tsx @@ -7,8 +7,9 @@ import React, { memo } from 'react'; import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; import { TableGroup } from '../table_vis_response_handler'; -import { TableVisConfig, TableUiState } from '../types'; +import { TableVisConfig } from '../types'; import { TableVisComponent } from './table_vis_component'; +import { TableUiState } from '../utils'; interface TableVisGroupComponentProps { tableGroups: TableGroup[]; @@ -21,11 +22,11 @@ export const TableVisComponentGroup = memo( ({ tableGroups, visConfig, event, uiState }: TableVisGroupComponentProps) => { return ( <> - {tableGroups.map(({ tables, title }) => ( + {tableGroups.map(({ table, title }) => (
{ const filterBucket = (rowIndex: number, columnIndex: number, negate: boolean) => { - const foramttedColumnId = cols[columnIndex].id; - const rawColumnIndex = table.columns.findIndex((col) => col.id === foramttedColumnId); event({ name: 'filterBucket', data: { @@ -28,10 +23,10 @@ export const getDataGridColumns = ( { table: { columns: table.columns, - rows, + rows: table.rows, }, row: rowIndex, - column: rawColumnIndex, + column: columnIndex, }, ], negate, @@ -39,11 +34,11 @@ export const getDataGridColumns = ( }); }; - return cols.map((col, colIndex) => { + return table.formattedColumns.map((col, colIndex) => { const cellActions = col.filterable ? [ ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { - const filterValue = rows[rowIndex][columnId]; + const filterValue = table.rows[rowIndex][columnId]; const filterContent = col.formatter?.convert(filterValue); const filterForValueText = i18n.translate( @@ -79,7 +74,7 @@ export const getDataGridColumns = ( ); }, ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { - const filterValue = rows[rowIndex][columnId]; + const filterValue = table.rows[rowIndex][columnId]; const filterContent = col.formatter?.convert(filterValue); const filterOutValueText = i18n.translate( diff --git a/src/plugins/vis_type_table/public/table_vis_fn.ts b/src/plugins/vis_type_table/public/table_vis_fn.ts index 4f0fb2c0ba1f..556cfaf24e00 100644 --- a/src/plugins/vis_type_table/public/table_vis_fn.ts +++ b/src/plugins/vis_type_table/public/table_vis_fn.ts @@ -4,7 +4,7 @@ */ import { i18n } from '@osd/i18n'; -import { tableVisResponseHandler, TableContext } from './table_vis_response_handler'; +import { tableVisResponseHandler, TableVisData } from './table_vis_response_handler'; import { ExpressionFunctionDefinition, OpenSearchDashboardsDatatable, @@ -19,7 +19,7 @@ interface Arguments { } export interface TableVisRenderValue { - visData: TableContext; + visData: TableVisData; visType: 'table'; visConfig: TableVisConfig; } diff --git a/src/plugins/vis_type_table/public/table_vis_renderer.test.tsx b/src/plugins/vis_type_table/public/table_vis_renderer.test.tsx new file mode 100644 index 000000000000..ee18bcfaf734 --- /dev/null +++ b/src/plugins/vis_type_table/public/table_vis_renderer.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { unmountComponentAtNode } from 'react-dom'; +import { act } from '@testing-library/react'; + +import { CoreStart } from 'opensearch-dashboards/public'; +import { getTableVisRenderer } from './table_vis_renderer'; +import { TableVisData } from './table_vis_response_handler'; +import { TableVisConfig } from './types'; +import { TableVisRenderValue } from './table_vis_fn'; + +const mockVisData = { + tableGroups: [], + direction: 'row', +} as TableVisData; + +const mockVisConfig = { + title: 'My Table', + metrics: [] as any, + buckets: [] as any, +} as TableVisConfig; + +const mockHandlers = { + done: jest.fn(), + reload: jest.fn(), + update: jest.fn(), + event: jest.fn(), + onDestroy: jest.fn(), +}; + +const mockCoreStart = {} as CoreStart; + +describe('getTableVisRenderer', () => { + let container: any = null; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it('should render table visualization', async () => { + const renderer = getTableVisRenderer(mockCoreStart); + const mockTableVisRenderValue = { + visData: mockVisData, + visType: 'table', + visConfig: mockVisConfig, + } as TableVisRenderValue; + await act(async () => { + renderer.render(container, mockTableVisRenderValue, mockHandlers); + }); + expect(container.querySelector('.tableVis')).toBeTruthy(); + }); + + it('should destroy table on unmount', async () => { + const renderer = getTableVisRenderer(mockCoreStart); + const mockTableVisRenderValue = { + visData: mockVisData, + visType: 'table', + visConfig: mockVisConfig, + } as TableVisRenderValue; + await act(async () => { + renderer.render(container, mockTableVisRenderValue, mockHandlers); + }); + await act(async () => { + unmountComponentAtNode(container); + }); + expect(mockHandlers.onDestroy).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_table/public/table_vis_response_handler.test.ts b/src/plugins/vis_type_table/public/table_vis_response_handler.test.ts new file mode 100644 index 000000000000..89627854b449 --- /dev/null +++ b/src/plugins/vis_type_table/public/table_vis_response_handler.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { tableVisResponseHandler } from './table_vis_response_handler'; + +jest.mock('./services', () => { + const formatService = { + deserialize: jest.fn(() => ({ + convert: jest.fn((value) => value), + })), + }; + + return { + getFormatService: () => formatService, + }; +}); + +const createTableGroup = (title, rows) => ({ + title, + table: { + columns: [ + { id: 'col-0', meta: { type: 'string' }, name: 'Column 1' }, + { id: 'col-1', meta: { type: 'number' }, name: 'Column 2' }, + ], + formattedColumns: [ + { + id: 'col-0', + title: 'Column 1', + formatter: { convert: expect.any(Function) }, + filterable: true, + }, + { + id: 'col-1', + title: 'Column 2', + formatter: { convert: expect.any(Function) }, + filterable: false, + }, + ], + rows, + }, +}); + +describe('tableVisResponseHandler', () => { + const input = { + type: 'datatable', + columns: [ + { id: 'col-0', name: 'Column 1', meta: { type: 'string' } }, + { id: 'col-1', name: 'Column 2', meta: { type: 'number' } }, + ], + rows: [ + { 'col-0': 'Group 1', 'col-1': 100 }, + { 'col-0': 'Group 2', 'col-1': 200 }, + ], + }; + + const baseVisConfig = { + title: 'My Table', + buckets: [ + { + accessor: 0, + label: 'Column 1', + format: { + id: 'string', + params: {}, + }, + params: {}, + aggType: 'terms', + }, + ], + metrics: [ + { + accessor: 1, + label: 'Count', + format: { + id: 'number', + }, + params: {}, + aggType: 'count', + }, + ], + }; + + const splitConfig = { + accessor: 0, + label: 'Column 1', + format: { + id: 'string', + params: {}, + }, + params: {}, + aggType: 'terms', + }; + + it('should correctly format data with splitRow', () => { + const visConfig = { ...baseVisConfig, splitRow: [splitConfig] }; + + const expected = { + table: undefined, + tableGroups: [ + createTableGroup('Group 1: Column 1', [{ 'col-0': 'Group 1', 'col-1': 100 }]), + createTableGroup('Group 2: Column 1', [{ 'col-0': 'Group 2', 'col-1': 200 }]), + ], + direction: 'row', + }; + + const result = tableVisResponseHandler(input, visConfig); + expect(result).toEqual(expected); + }); + + it('should correctly format data with splitColumn', () => { + const visConfig = { ...baseVisConfig, splitColumn: [splitConfig] }; + + const expected = { + table: undefined, + tableGroups: [ + createTableGroup('Group 1: Column 1', [{ 'col-0': 'Group 1', 'col-1': 100 }]), + createTableGroup('Group 2: Column 1', [{ 'col-0': 'Group 2', 'col-1': 200 }]), + ], + direction: 'column', + }; + + const result = tableVisResponseHandler(input, visConfig); + expect(result).toEqual(expected); + }); + + it('should correctly format data with no split', () => { + const visConfig = baseVisConfig; + + const expected = { + table: { + columns: input.columns, + formattedColumns: [ + { + id: 'col-0', + title: 'Column 1', + formatter: { convert: expect.any(Function) }, + filterable: true, + }, + { + id: 'col-1', + title: 'Column 2', + formatter: { convert: expect.any(Function) }, + filterable: false, + }, + ], + rows: input.rows, + }, + tableGroups: [], + direction: undefined, + }; + + const result = tableVisResponseHandler(input, visConfig); + expect(result).toEqual(expected); + }); +}); 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 b1d41edfff8b..975038c4c11f 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 @@ -30,25 +30,25 @@ import { getFormatService } from './services'; import { OpenSearchDashboardsDatatable } from '../../expressions/public'; -import { TableVisConfig } from './types'; +import { FormattedColumn, TableVisConfig } from './types'; +import { convertToFormattedData } from './utils/convert_to_formatted_data'; export interface Table { columns: OpenSearchDashboardsDatatable['columns']; rows: OpenSearchDashboardsDatatable['rows']; } +export interface FormattedTableContext extends Table { + formattedColumns: FormattedColumn[]; +} + export interface TableGroup { - table: OpenSearchDashboardsDatatable; - tables: Table[]; + table: FormattedTableContext; title: string; - name: string; - key: any; - column: number; - row: number; } -export interface TableContext { - table?: Table; +export interface TableVisData { + table?: FormattedTableContext; tableGroups: TableGroup[]; direction?: 'row' | 'column'; } @@ -56,10 +56,10 @@ export interface TableContext { export function tableVisResponseHandler( input: OpenSearchDashboardsDatatable, config: TableVisConfig -): TableContext { - let table: Table | undefined; +): TableVisData { + let table: FormattedTableContext | undefined; const tableGroups: TableGroup[] = []; - let direction: TableContext['direction']; + let direction: TableVisData['direction']; const split = config.splitColumn || config.splitRow; @@ -78,30 +78,32 @@ export function tableVisResponseHandler( (splitMap as any)[splitValue] = splitIndex++; const tableGroup: TableGroup = { title: `${splitColumnFormatter.convert(splitValue)}: ${splitColumn.name}`, - name: splitColumn.name, - key: splitValue, - column: splitColumnIndex, - row: rowIndex, - table: input, - tables: [], + table: { + formattedColumns: [], + rows: [], + columns: input.columns, + }, }; - tableGroup.tables.push({ - columns: input.columns, - rows: [], - }); - tableGroups.push(tableGroup); } - const tableIndex = (splitMap as any)[splitValue]; - (tableGroups[tableIndex] as any).tables[0].rows.push(row); + const tableIndex = splitMap[splitValue]; + tableGroups[tableIndex].table.rows.push(row); + }); + + // format tables + tableGroups.forEach((tableGroup) => { + tableGroup.table = convertToFormattedData(tableGroup.table, config); }); } else { - table = { - columns: input.columns, - rows: input.rows, - }; + table = convertToFormattedData( + { + columns: input.columns, + rows: input.rows, + }, + config + ); } return { diff --git a/src/plugins/vis_type_table/public/types.ts b/src/plugins/vis_type_table/public/types.ts index 814a86f5ac69..a14767f96302 100644 --- a/src/plugins/vis_type_table/public/types.ts +++ b/src/plugins/vis_type_table/public/types.ts @@ -75,10 +75,3 @@ export interface ColumnSort { colIndex?: number; direction?: 'asc' | 'desc'; } - -export interface TableUiState { - sort: ColumnSort; - setSort: (sort: ColumnSort) => void; - width: ColumnWidth[]; - setWidth: (columnWidths: ColumnWidth[]) => void; -} diff --git a/src/plugins/vis_type_table/public/utils/__snapshots__/add_percentage_col.test.ts.snap b/src/plugins/vis_type_table/public/utils/__snapshots__/add_percentage_col.test.ts.snap new file mode 100644 index 000000000000..41f4f24ecffb --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/__snapshots__/add_percentage_col.test.ts.snap @@ -0,0 +1,170 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`addPercentageCol should add new percentage column 1`] = ` +Object { + "cols": Array [ + Object { + "filterable": true, + "formatter": Object {}, + "id": "col-0-2", + "title": "name.keyword: Descending", + }, + Object { + "filterable": false, + "formatter": Object {}, + "id": "col-1-1", + "sumTotal": 5, + "title": "Count", + }, + Object { + "filterable": false, + "formatter": Object {}, + "id": "col-1-1-percents", + "title": "count percentages", + }, + ], + "rows": Array [ + Object { + "col-0-2": "Alice", + "col-1-1": 3, + "col-1-1-percents": 0.6, + }, + Object { + "col-0-2": "Anthony", + "col-1-1": 1, + "col-1-1-percents": 0.2, + }, + Object { + "col-0-2": "Timmy", + "col-1-1": 1, + "col-1-1-percents": 0.2, + }, + ], +} +`; + +exports[`addPercentageCol should handle empty input data 1`] = ` +Object { + "cols": Array [], + "rows": Array [], +} +`; + +exports[`addPercentageCol should handle input data with null values 1`] = ` +Object { + "cols": Array [ + Object { + "filterable": true, + "formatter": Object {}, + "id": "col-0-2", + "title": "name.keyword: Descending", + }, + Object { + "filterable": false, + "formatter": Object {}, + "id": "col-1-1", + "sumTotal": 5, + "title": "Count", + }, + Object { + "filterable": false, + "formatter": Object {}, + "id": "col-1-1-percents", + "title": "count percentages", + }, + ], + "rows": Array [ + Object { + "col-0-2": "Alice", + "col-1-1": null, + "col-1-1-percents": 0, + }, + Object { + "col-0-2": "Anthony", + "col-1-1": null, + "col-1-1-percents": 0, + }, + Object { + "col-0-2": "Timmy", + "col-1-1": null, + "col-1-1-percents": 0, + }, + ], +} +`; + +exports[`addPercentageCol should handle input data with one row 1`] = ` +Object { + "cols": Array [ + Object { + "filterable": true, + "formatter": Object {}, + "id": "col-0-2", + "title": "name.keyword: Descending", + }, + Object { + "filterable": false, + "formatter": Object {}, + "id": "col-1-1", + "sumTotal": 5, + "title": "Count", + }, + Object { + "filterable": false, + "formatter": Object {}, + "id": "col-1-1-percents", + "title": "count percentages", + }, + ], + "rows": Array [ + Object { + "col-0-2": "Alice", + "col-1-1": 3, + "col-1-1-percents": 0.6, + }, + ], +} +`; + +exports[`addPercentageCol should handle sumTotal being 0 1`] = ` +Object { + "cols": Array [ + Object { + "filterable": true, + "formatter": Object {}, + "id": "col-0-2", + "title": "name.keyword: Descending", + }, + Object { + "filterable": false, + "formatter": Object {}, + "id": "col-1-1", + "sumTotal": 0, + "title": "Count", + }, + Object { + "filterable": false, + "formatter": Object {}, + "id": "col-1-1-percents", + "title": "count percentages", + }, + ], + "rows": Array [ + Object { + "col-0-2": "Alice", + "col-1-1": 3, + "col-1-1-percents": 0, + }, + Object { + "col-0-2": "Anthony", + "col-1-1": 1, + "col-1-1-percents": 0, + }, + Object { + "col-0-2": "Timmy", + "col-1-1": 1, + "col-1-1-percents": 0, + }, + ], +} +`; diff --git a/src/plugins/vis_type_table/public/utils/add_percentage_col.test.ts b/src/plugins/vis_type_table/public/utils/add_percentage_col.test.ts new file mode 100644 index 000000000000..5f27cb5e49ea --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/add_percentage_col.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { addPercentageCol } from './add_percentage_col'; +import { FormattedColumn } from '../types'; +import { Table } from '../table_vis_response_handler'; + +const mockDeserialize = jest.fn(() => ({})); +jest.mock('../services', () => ({ + getFormatService: jest.fn(() => ({ + deserialize: mockDeserialize, + })), +})); + +let formattedColumns: FormattedColumn[]; +const rows = [ + { 'col-0-2': 'Alice', 'col-1-1': 3 }, + { 'col-0-2': 'Anthony', 'col-1-1': 1 }, + { 'col-0-2': 'Timmy', 'col-1-1': 1 }, +] as Table['rows']; + +beforeEach(() => { + formattedColumns = [ + { + id: 'col-0-2', + title: 'name.keyword: Descending', + formatter: {}, + filterable: true, + }, + { + id: 'col-1-1', + title: 'Count', + formatter: {}, + filterable: false, + sumTotal: 5, + }, + ] as FormattedColumn[]; +}); + +describe('addPercentageCol', () => { + it('should add new percentage column', () => { + const result = addPercentageCol(formattedColumns, 'count', rows, 1); + expect(result).toMatchSnapshot(); + }); + + it('should handle sumTotal being 0', () => { + formattedColumns[1].sumTotal = 0; + const result = addPercentageCol(formattedColumns, 'count', rows, 1); + expect(result).toMatchSnapshot(); + }); + + it('should handle empty input data', () => { + const emptyFormattedColumns: FormattedColumn[] = []; + const emptyRows: Table['rows'] = []; + const result = addPercentageCol(emptyFormattedColumns, 'count', emptyRows, 1); + expect(result).toMatchSnapshot(); + }); + + it('should handle input data with one row', () => { + const oneRow = [{ 'col-0-2': 'Alice', 'col-1-1': 3 }] as Table['rows']; + const result = addPercentageCol(formattedColumns, 'count', oneRow, 1); + expect(result).toMatchSnapshot(); + }); + + it('should handle input data with null values', () => { + const nullValueRows = [ + { 'col-0-2': 'Alice', 'col-1-1': null }, + { 'col-0-2': 'Anthony', 'col-1-1': null }, + { 'col-0-2': 'Timmy', 'col-1-1': null }, + ] as Table['rows']; + const result = addPercentageCol(formattedColumns, 'count', nullValueRows, 1); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_type_table/public/utils/add_percentage_col.ts b/src/plugins/vis_type_table/public/utils/add_percentage_col.ts new file mode 100644 index 000000000000..8c300f29d06a --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/add_percentage_col.ts @@ -0,0 +1,76 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@osd/i18n'; +import { Table } from '../table_vis_response_handler'; +import { getFormatService } from '../services'; +import { FormattedColumn } from '../types'; + +function insert(arr: FormattedColumn[], index: number, col: FormattedColumn) { + const newArray = [...arr]; + newArray.splice(index + 1, 0, col); + return newArray; +} +/** + * @param columns - the formatted columns that will be displayed + * @param title - the title of the column to add to + * @param rows - the row data for the columns + * @param insertAtIndex - the index to insert the percentage column at + * @returns cols and rows for the table to render now included percentage column(s) + */ +export function addPercentageCol( + columns: FormattedColumn[], + title: string, + rows: Table['rows'], + insertAtIndex: number +) { + if (columns.length === 0) { + return { cols: columns, rows }; + } + 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 = insert(columns, insertAtIndex, { + title: i18nTitle, + id: newId, + formatter, + filterable: false, + }); + const newRows = rows.map((row) => ({ + [newId]: sumTotal === 0 ? 0 : (row[id] as number) / (sumTotal as number), + ...row, + })); + + return { cols: newCols, rows: newRows }; +} diff --git a/src/plugins/vis_type_table/public/utils/convert_to_csv_data.test.ts b/src/plugins/vis_type_table/public/utils/convert_to_csv_data.test.ts new file mode 100644 index 000000000000..591dbe5454ce --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/convert_to_csv_data.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IUiSettingsClient } from 'opensearch-dashboards/public'; +import { FormattedColumn } from '../types'; +import { toCsv } from './convert_to_csv_data'; +import { IFieldFormat } from 'src/plugins/data/common'; + +const mockConvert = jest.fn((x) => x); +const defaultFormatter = { convert: (x) => mockConvert(x) } as IFieldFormat; + +function implementConvert(nRow: number) { + for (let i = 0; i < nRow; i++) { + mockConvert.mockImplementationOnce((x) => x); + mockConvert.mockImplementationOnce((x) => x); + mockConvert.mockImplementationOnce((x) => { + return parseFloat(x) * 100 + '%'; + }); + } +} + +const columns = [ + { + id: 'col-0-2', + title: 'name.keyword: Descending', + formatter: defaultFormatter, + filterable: true, + }, + { + id: 'col-1-1', + title: 'Count', + formatter: defaultFormatter, + filterable: false, + sumTotal: 5, + formattedTotal: 5, + total: 5, + }, + { + id: 'col-1-1-percents', + title: 'Count percentages', + formatter: defaultFormatter, + filterable: false, + }, +] as FormattedColumn[]; + +const rows = [ + { 'col-1-1-percents': 0.6, 'col-0-2': 'Alice', 'col-1-1': 3 }, + { 'col-1-1-percents': 0.2, 'col-0-2': 'Anthony', 'col-1-1': 1 }, + { 'col-1-1-percents': 0.2, 'col-0-2': 'Timmy', 'col-1-1': 1 }, +]; + +const uiSettings = { + get: (key: string) => { + if (key === 'csv:separator') return ','; + else if (key === 'csv:quoteValues') return true; + }, +} as IUiSettingsClient; + +describe('toCsv', () => { + it('should create csv rows if not formatted', () => { + const result = toCsv(false, { rows, columns, uiSettings }); + expect(result).toEqual( + '"name.keyword: Descending",Count,"Count percentages"\r\nAlice,3,"0.6"\r\nAnthony,1,"0.2"\r\nTimmy,1,"0.2"\r\n' + ); + }); + + it('should create csv rows if formatted', () => { + implementConvert(3); + const result = toCsv(true, { rows, columns, uiSettings }); + expect(result).toEqual( + '"name.keyword: Descending",Count,"Count percentages"\r\nAlice,3,"60%"\r\nAnthony,1,"20%"\r\nTimmy,1,"20%"\r\n' + ); + }); +}); diff --git a/src/plugins/vis_type_table/public/utils/convert_to_csv_data.ts b/src/plugins/vis_type_table/public/utils/convert_to_csv_data.ts index 2c37df1aa3d5..3d4781736689 100644 --- a/src/plugins/vis_type_table/public/utils/convert_to_csv_data.ts +++ b/src/plugins/vis_type_table/public/utils/convert_to_csv_data.ts @@ -46,7 +46,7 @@ interface CSVDataProps { uiSettings: CoreStart['uiSettings']; } -const toCsv = function (formatted: boolean, { rows, columns, uiSettings }: CSVDataProps) { +export const toCsv = function (formatted: boolean, { rows, columns, uiSettings }: CSVDataProps) { const separator = uiSettings.get(CSV_SEPARATOR_SETTING); const quoteValues = uiSettings.get(CSV_QUOTE_VALUES_SETTING); diff --git a/src/plugins/vis_type_table/public/utils/convert_to_formatted_data.test.ts b/src/plugins/vis_type_table/public/utils/convert_to_formatted_data.test.ts new file mode 100644 index 000000000000..34085b70a278 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/convert_to_formatted_data.test.ts @@ -0,0 +1,136 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { convertToFormattedData } from './convert_to_formatted_data'; +import { TableVisConfig } from '../types'; +import { Table } from '../table_vis_response_handler'; +import { AggTypes } from '../types'; + +const mockDeserialize = jest.fn(() => ({})); +jest.mock('../services', () => ({ + getFormatService: jest.fn(() => ({ + deserialize: mockDeserialize, + })), +})); + +const table = { + type: 'opensearch_dashboards_datatable', + columns: [ + { id: 'col-0-2', name: 'name.keyword: Descending', meta: { type: 'terms' } }, + { id: 'col-1-1', name: 'Count', meta: { type: 'count' } }, + ], + rows: [ + { 'col-0-2': 'Alice', 'col-1-1': 3 }, + { 'col-0-2': 'Anthony', 'col-1-1': 1 }, + { 'col-0-2': 'Timmy', 'col-1-1': 1 }, + ], +} as Table; + +let visConfig = {} as TableVisConfig; + +function implementDeserialize() { + mockDeserialize.mockImplementationOnce(() => ({})); + mockDeserialize.mockImplementationOnce(() => ({ + allowsNumericalAggregations: true, + convert: jest.fn((x: number) => x), + })); +} + +describe('convertToFormattedData', () => { + beforeEach(() => { + visConfig = { + buckets: [ + { + accessor: 0, + aggType: 'terms', + format: { + id: 'terms', + params: { + id: 'string', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + parsedUrl: { + basePath: '/arf', + origin: '', + pathname: '/arf/app/home', + }, + }, + }, + label: 'name.keyword: Descending', + params: {}, + }, + ], + metrics: [ + { + accessor: 1, + aggType: 'count', + format: { + id: 'number', + }, + label: 'Count', + params: {}, + }, + ], + perPage: 10, + percentageCol: '', + showMetricsAtAllLevels: false, + showPartialRows: false, + showTotal: false, + title: '', + totalFunc: 'sum', + } as TableVisConfig; + }); + + it('should create formatted data', () => { + const result = convertToFormattedData(table, visConfig); + expect(result.rows).toEqual(table.rows); + expect(result.formattedColumns).toEqual([ + { + id: 'col-0-2', + title: 'name.keyword: Descending', + formatter: {}, + filterable: true, + }, + { id: 'col-1-1', title: 'Count', formatter: {}, filterable: false }, + ]); + }); + + describe.each([ + [AggTypes.SUM, 5], + [AggTypes.AVG, 1.6666666666666667], + [AggTypes.MIN, 1], + [AggTypes.MAX, 3], + [AggTypes.COUNT, 3], + ])('with totalFunc as %s', (totalFunc, expectedTotal) => { + beforeEach(() => { + implementDeserialize(); + visConfig.showTotal = true; + visConfig.totalFunc = totalFunc; + }); + + it(`should add ${totalFunc} total`, () => { + const result = convertToFormattedData(table, visConfig); + const expectedFormattedColumns = [ + { + id: 'col-0-2', + title: 'name.keyword: Descending', + formatter: {}, + filterable: true, + ...(totalFunc === AggTypes.COUNT ? { sumTotal: 0, formattedTotal: 3, total: 3 } : {}), + }, + { + id: 'col-1-1', + title: 'Count', + formatter: { allowsNumericalAggregations: true, convert: expect.any(Function) }, + filterable: false, + sumTotal: 5, + formattedTotal: expectedTotal, + total: expectedTotal, + }, + ]; + expect(result.formattedColumns).toEqual(expectedFormattedColumns); + }); + }); +}); diff --git a/src/plugins/vis_type_table/public/utils/convert_to_formatted_data.ts b/src/plugins/vis_type_table/public/utils/convert_to_formatted_data.ts index 2ab67e3b0a67..afb2906af8a1 100644 --- a/src/plugins/vis_type_table/public/utils/convert_to_formatted_data.ts +++ b/src/plugins/vis_type_table/public/utils/convert_to_formatted_data.ts @@ -28,56 +28,20 @@ * under the License. */ -import { i18n } from '@osd/i18n'; import { chain } from 'lodash'; -import { OpenSearchDashboardsDatatableRow } from 'src/plugins/expressions'; +import { + OpenSearchDashboardsDatatableRow, + OpenSearchDashboardsDatatableColumn, +} from 'src/plugins/expressions'; import { Table } from '../table_vis_response_handler'; import { AggTypes, TableVisConfig } from '../types'; import { getFormatService } from '../services'; import { FormattedColumn } from '../types'; - -function insert(arr: FormattedColumn[], index: number, col: FormattedColumn) { - const newArray = [...arr]; - newArray.splice(index + 1, 0, col); - return newArray; -} - -/** - * @param columns - the formatted columns that will be displayed - * @param title - the title of the column to add to - * @param rows - the row data for the columns - * @param insertAtIndex - the index to insert the percentage column at - * @returns cols and rows for the table to render now included percentage column(s) - */ -function addPercentageCol( - columns: FormattedColumn[], - title: string, - rows: Table['rows'], - insertAtIndex: number -) { - const { id, sumTotal } = columns[insertAtIndex]; - const newId = `${id}-percents`; - const formatter = getFormatService().deserialize({ id: 'percent' }); - const i18nTitle = i18n.translate('visTypeTable.params.percentageTableColumnName', { - defaultMessage: '{title} percentages', - values: { title }, - }); - const newCols = insert(columns, insertAtIndex, { - title: i18nTitle, - id: newId, - formatter, - filterable: false, - }); - const newRows = rows.map((row) => ({ - [newId]: (row[id] as number) / (sumTotal as number), - ...row, - })); - - return { cols: newCols, rows: newRows }; -} +import { addPercentageCol } from './add_percentage_col'; export interface FormattedDataProps { - formattedRows: OpenSearchDashboardsDatatableRow[]; + rows: OpenSearchDashboardsDatatableRow[]; + columns: OpenSearchDashboardsDatatableColumn[]; formattedColumns: FormattedColumn[]; } @@ -107,12 +71,15 @@ export const convertToFormattedData = ( const allowsNumericalAggregations = formatter?.allowsNumericalAggregations; if (allowsNumericalAggregations || isDate || visConfig.totalFunc === AggTypes.COUNT) { - const sum = table.rows.reduce((prev, curr) => { - // some metrics return undefined for some of the values - // derivative is an example of this as it returns undefined in the first row - if (curr[col.id] === undefined) return prev; - return prev + (curr[col.id] as number); - }, 0); + // only calculate the sumTotal for numerical columns + const sum = isBucket + ? 0 + : table.rows.reduce((prev, curr) => { + // some metrics return undefined for some of the values + // derivative is an example of this as it returns undefined in the first row + if (curr[col.id] === undefined) return prev; + return prev + (curr[col.id] as number); + }, 0); formattedColumn.sumTotal = sum; switch (visConfig.totalFunc) { @@ -164,7 +131,7 @@ export const convertToFormattedData = ( ); // column to show percentage was removed - if (insertAtIndex < 0) return { formattedRows, formattedColumns }; + if (insertAtIndex < 0) return { rows: table.rows, columns: table.columns, formattedColumns }; const { cols, rows } = addPercentageCol( formattedColumns, @@ -175,5 +142,5 @@ export const convertToFormattedData = ( formattedRows = rows; formattedColumns = cols; } - return { formattedRows, formattedColumns }; + return { rows: formattedRows, columns: table.columns, formattedColumns }; }; diff --git a/src/plugins/vis_type_table/public/utils/get_table_ui_state.test.ts b/src/plugins/vis_type_table/public/utils/get_table_ui_state.test.ts new file mode 100644 index 000000000000..64488d9275eb --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/get_table_ui_state.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PersistedState } from '../../../visualizations/public'; +import { TableUiState, getTableUIState } from './get_table_ui_state'; +import { ColumnWidth, ColumnSort } from '../types'; + +describe('getTableUIState', () => { + let uiState: PersistedState; + let tableUiState: TableUiState; + + beforeEach(() => { + uiState = ({ + get: jest.fn(), + set: jest.fn(), + emit: jest.fn(), + } as unknown) as PersistedState; + tableUiState = getTableUIState(uiState); + }); + + it('should get initial sort and width values from uiState', () => { + const initialSort: ColumnSort = { colIndex: 1, direction: 'asc' }; + const initialWidth: ColumnWidth[] = [{ colIndex: 0, width: 100 }]; + + (uiState.get as jest.Mock).mockImplementation((key: string) => { + if (key === 'vis.sortColumn') return initialSort; + if (key === 'vis.columnsWidth') return initialWidth; + }); + + const newTableUiState = getTableUIState(uiState); + expect(newTableUiState.sort).toEqual(initialSort); + expect(newTableUiState.colWidth).toEqual(initialWidth); + }); + + it('should set and emit sort values', () => { + const newSort: ColumnSort = { colIndex: 2, direction: 'desc' }; + tableUiState.setSort(newSort); + + expect(uiState.set).toHaveBeenCalledWith('vis.sortColumn', newSort); + expect(uiState.emit).toHaveBeenCalledWith('reload'); + }); + + it('should set and emit width values for a new column', () => { + const newWidth: ColumnWidth = { colIndex: 1, width: 150 }; + tableUiState.setWidth(newWidth); + + expect(uiState.set).toHaveBeenCalledWith('vis.columnsWidth', [newWidth]); + expect(uiState.emit).toHaveBeenCalledWith('reload'); + }); + + it('should update and emit width values for an existing column', () => { + const initialWidth: ColumnWidth[] = [{ colIndex: 0, width: 100 }]; + (uiState.get as jest.Mock).mockReturnValue(initialWidth); + + const updatedTableUiState = getTableUIState(uiState); + + const updatedWidth: ColumnWidth = { colIndex: 0, width: 150 }; + updatedTableUiState.setWidth(updatedWidth); + + const expectedWidths = [{ colIndex: 0, width: 150 }]; + expect(uiState.set).toHaveBeenCalledWith('vis.columnsWidth', expectedWidths); + expect(uiState.emit).toHaveBeenCalledWith('reload'); + }); +}); diff --git a/src/plugins/vis_type_table/public/utils/get_table_ui_state.ts b/src/plugins/vis_type_table/public/utils/get_table_ui_state.ts new file mode 100644 index 000000000000..58fc6b472a40 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/get_table_ui_state.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PersistedState } from '../../../visualizations/public'; +import { ColumnSort, ColumnWidth } from '../types'; + +export interface TableUiState { + sort: ColumnSort; + setSort: (sort: ColumnSort) => void; + colWidth: ColumnWidth[]; + setWidth: (columnWidths: ColumnWidth) => void; +} + +export function getTableUIState(uiState: PersistedState): TableUiState { + const sort: ColumnSort = uiState.get('vis.sortColumn') || {}; + const colWidth: ColumnWidth[] = uiState.get('vis.columnsWidth') || []; + + const setSort = (newSort: ColumnSort) => { + uiState.set('vis.sortColumn', newSort); + uiState.emit('reload'); + }; + + const setWidth = (columnWidth: ColumnWidth) => { + const nextState = [...colWidth]; + const curColIndex = colWidth.findIndex((col) => col.colIndex === columnWidth.colIndex); + + if (curColIndex < 0) { + nextState.push(columnWidth); + } else { + nextState[curColIndex] = columnWidth; + } + + uiState.set('vis.columnsWidth', nextState); + uiState.emit('reload'); + }; + + return { sort, setSort, colWidth, setWidth }; +} diff --git a/src/plugins/vis_type_table/public/utils/index.ts b/src/plugins/vis_type_table/public/utils/index.ts index 1fd0e3f1e0fd..3277d92efc71 100644 --- a/src/plugins/vis_type_table/public/utils/index.ts +++ b/src/plugins/vis_type_table/public/utils/index.ts @@ -6,3 +6,5 @@ export * from './convert_to_csv_data'; export * from './convert_to_formatted_data'; export * from './use_pagination'; +export * from './add_percentage_col'; +export * from './get_table_ui_state'; diff --git a/src/plugins/vis_type_table/public/utils/use_pagination.test.ts b/src/plugins/vis_type_table/public/utils/use_pagination.test.ts new file mode 100644 index 000000000000..d8e7a02a0799 --- /dev/null +++ b/src/plugins/vis_type_table/public/utils/use_pagination.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { AggTypes, TableVisParams } from '../types'; +import { usePagination } from './use_pagination'; + +describe('usePagination', () => { + const visParams = { + perPage: 10, + showPartialRows: false, + showMetricsAtAllLevels: false, + showTotal: false, + totalFunc: AggTypes.SUM, + percentageCol: '', + } as TableVisParams; + + it('should not set pagination if perPage is empty string', () => { + const params = { + ...visParams, + perPage: '', + }; + const { result } = renderHook(() => usePagination(params, 20)); + expect(result.current).toEqual(undefined); + }); + + it('should init pagination', () => { + const { result } = renderHook(() => usePagination(visParams, 20)); + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 10, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + }); + }); + + it('should init pagination with pageSize as the minimum of perPage and nRow', () => { + const { result } = renderHook(() => usePagination(visParams, 8)); + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 8, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + }); + }); + + it('should set pageSize to the lesser of perPage and nRow when nRow is less than perPage', () => { + const { result } = renderHook(() => usePagination(visParams, 5)); + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 5, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + }); + }); + + it('should set page index via onChangePage', () => { + const { result } = renderHook(() => usePagination(visParams, 50)); + act(() => { + // set page index to 3 + result.current?.onChangePage(3); + }); + expect(result.current?.pageIndex).toEqual(3); + }); + + it('should set to max page index via onChangePage if exceed maxiPageIndex', () => { + const { result, rerender } = renderHook((props) => usePagination(props.visParams, props.nRow), { + initialProps: { + visParams, + nRow: 55, + }, + }); + + act(() => { + // set page index to the last page + result.current?.onChangePage(5); + }); + + rerender({ visParams, nRow: 15 }); + // when the number of rows decreases, page index should + // be set to maxiPageIndex + expect(result.current).toEqual({ + pageIndex: 1, + pageSize: 10, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + }); + }); + + it('should pagination via onChangeItemsPerPage', () => { + const { result } = renderHook(() => usePagination(visParams, 20)); + act(() => { + // set page size to 5 + result.current?.onChangeItemsPerPage(5); + }); + + expect(result.current?.pageSize).toEqual(5); + }); +}); diff --git a/src/plugins/vis_type_table/public/utils/use_pagination.ts b/src/plugins/vis_type_table/public/utils/use_pagination.ts index e3993f1c0868..97ecfd6b85e6 100644 --- a/src/plugins/vis_type_table/public/utils/use_pagination.ts +++ b/src/plugins/vis_type_table/public/utils/use_pagination.ts @@ -4,12 +4,12 @@ */ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { TableVisConfig } from '../types'; +import { TableVisParams } from '../types'; -export const usePagination = (visConfig: TableVisConfig, nRow: number) => { +export const usePagination = (visParams: TableVisParams, nRow: number) => { const [pagination, setPagination] = useState({ pageIndex: 0, - pageSize: Math.min(visConfig.perPage || 10, nRow), + pageSize: Math.min(visParams.perPage || 0, nRow), }); const onChangeItemsPerPage = useCallback( (pageSize) => setPagination((p) => ({ ...p, pageSize, pageIndex: 0 })), @@ -20,20 +20,23 @@ export const usePagination = (visConfig: TableVisConfig, nRow: number) => { ]); useEffect(() => { - const perPage = Math.min(visConfig.perPage || 10, nRow); + const perPage = Math.min(visParams.perPage || 0, nRow); const maxiPageIndex = Math.ceil(nRow / perPage) - 1; setPagination((p) => ({ pageIndex: p.pageIndex > maxiPageIndex ? maxiPageIndex : p.pageIndex, pageSize: perPage, })); - }, [nRow, visConfig.perPage]); + }, [nRow, visParams.perPage]); return useMemo( - () => ({ - ...pagination, - onChangeItemsPerPage, - onChangePage, - }), + () => + pagination.pageSize + ? { + ...pagination, + onChangeItemsPerPage, + onChangePage, + } + : undefined, [pagination, onChangeItemsPerPage, onChangePage] ); };