diff --git a/CHANGELOG.md b/CHANGELOG.md
index d3628963e9b5..8506726db478 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -71,6 +71,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- Use mirrors to download Node.js binaries to escape sporadic 404 errors ([#3619](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3619))
- [Multiple DataSource] Refactor dev tool console to use opensearch-js client to send requests ([#3544](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3544))
- [Data] Add geo shape filter field ([#3605](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3605))
+- [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.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..ae761f030e26 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,23 @@
*/
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 { uiStateManagement } from '../utils/ui_state_management';
+import { TableUiState } from '../utils/ui_state_management';
interface TableVisAppProps {
services: CoreStart;
- visData: TableContext;
+ visData: TableVisData;
visConfig: TableVisConfig;
handlers: IInterpreterRenderHandlers;
}
@@ -38,12 +41,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 = uiStateManagement(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..90c6b79057fd
--- /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 = 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;
+};
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..23c9009bb5a3
--- /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 { TableContext } 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 TableContext;
+
+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: visConfig.perPage,
+ 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..72cbada17624 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 { TableContext } 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/ui_state_management';
interface TableVisComponentProps {
title?: string;
- table: Table;
+ table: TableContext;
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 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 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(
+ () => getTableVisCellValue(sortedRows, formattedColumns) as EuiDataGridProps['renderCellValue'],
+ [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,40 +77,27 @@ 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;
@@ -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..5570da007e3e
--- /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 component 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..d4535ab2813d 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/ui_state_management';
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..a10f7b7d652c 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,27 @@
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 TableContext {
+ rows: OpenSearchDashboardsDatatable['rows'];
+ columns: OpenSearchDashboardsDatatable['columns'];
+ formattedColumns: FormattedColumn[];
+}
+
export interface TableGroup {
- table: OpenSearchDashboardsDatatable;
- tables: Table[];
+ table: TableContext;
title: string;
- name: string;
- key: any;
- column: number;
- row: number;
}
-export interface TableContext {
- table?: Table;
+export interface TableVisData {
+ table?: TableContext;
tableGroups: TableGroup[];
direction?: 'row' | 'column';
}
@@ -56,10 +58,10 @@ export interface TableContext {
export function tableVisResponseHandler(
input: OpenSearchDashboardsDatatable,
config: TableVisConfig
-): TableContext {
- let table: Table | undefined;
+): TableVisData {
+ let table: TableContext | undefined;
const tableGroups: TableGroup[] = [];
- let direction: TableContext['direction'];
+ let direction: TableVisData['direction'];
const split = config.splitColumn || config.splitRow;
@@ -78,30 +80,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/add_percentage_col.test.ts b/src/plugins/vis_type_table/public/utils/add_percentage_col.test.ts
new file mode 100644
index 000000000000..5ce90f116f3b
--- /dev/null
+++ b/src/plugins/vis_type_table/public/utils/add_percentage_col.test.ts
@@ -0,0 +1,154 @@
+/*
+ * 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).toEqual({
+ cols: [
+ {
+ id: 'col-0-2',
+ title: 'name.keyword: Descending',
+ formatter: {},
+ filterable: true,
+ },
+ {
+ id: 'col-1-1',
+ title: 'Count',
+ formatter: {},
+ filterable: false,
+ sumTotal: 5,
+ },
+ {
+ title: 'count percentages',
+ id: 'col-1-1-percents',
+ formatter: {},
+ filterable: false,
+ },
+ ],
+ 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 },
+ ],
+ });
+ });
+
+ it('should handle sumTotal being 0', () => {
+ formattedColumns[1].sumTotal = 0;
+ const result = addPercentageCol(formattedColumns, 'count', rows, 1);
+ expect(result).toEqual({
+ cols: [
+ {
+ id: 'col-0-2',
+ title: 'name.keyword: Descending',
+ formatter: {},
+ filterable: true,
+ },
+ {
+ id: 'col-1-1',
+ title: 'Count',
+ formatter: {},
+ filterable: false,
+ sumTotal: 0,
+ },
+ {
+ title: 'count percentages',
+ id: 'col-1-1-percents',
+ formatter: {},
+ filterable: false,
+ },
+ ],
+ rows: [
+ { 'col-1-1-percents': 0, 'col-0-2': 'Alice', 'col-1-1': 3 },
+ { 'col-1-1-percents': 0, 'col-0-2': 'Anthony', 'col-1-1': 1 },
+ { 'col-1-1-percents': 0, 'col-0-2': 'Timmy', 'col-1-1': 1 },
+ ],
+ });
+ });
+
+ it('should handle empty input data', () => {
+ const emptyFormattedColumns: FormattedColumn[] = [];
+ const emptyRows: Table['rows'] = [];
+ const result = addPercentageCol(emptyFormattedColumns, 'count', emptyRows, 1);
+
+ expect(result).toEqual({
+ cols: emptyFormattedColumns,
+ rows: emptyRows,
+ });
+ });
+
+ 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).toEqual({
+ cols: [
+ { id: 'col-0-2', title: 'name.keyword: Descending', formatter: {}, filterable: true },
+ { id: 'col-1-1', title: 'Count', formatter: {}, filterable: false, sumTotal: 5 },
+ { title: 'count percentages', id: 'col-1-1-percents', formatter: {}, filterable: false },
+ ],
+ rows: [{ 'col-1-1-percents': 0.6, 'col-0-2': 'Alice', 'col-1-1': 3 }],
+ });
+ });
+
+ 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).toEqual({
+ cols: [
+ { id: 'col-0-2', title: 'name.keyword: Descending', formatter: {}, filterable: true },
+ { id: 'col-1-1', title: 'Count', formatter: {}, filterable: false, sumTotal: 5 },
+ { title: 'count percentages', id: 'col-1-1-percents', formatter: {}, filterable: false },
+ ],
+ rows: [
+ { 'col-1-1-percents': 0, 'col-0-2': 'Alice', 'col-1-1': null },
+ { 'col-1-1-percents': 0, 'col-0-2': 'Anthony', 'col-1-1': null },
+ { 'col-1-1-percents': 0, 'col-0-2': 'Timmy', 'col-1-1': null },
+ ],
+ });
+ });
+});
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/index.ts b/src/plugins/vis_type_table/public/utils/index.ts
index 1fd0e3f1e0fd..063b00d0149f 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 './ui_state_management';
diff --git a/src/plugins/vis_type_table/public/utils/ui_state_management.test.ts b/src/plugins/vis_type_table/public/utils/ui_state_management.test.ts
new file mode 100644
index 000000000000..ffea90b13a08
--- /dev/null
+++ b/src/plugins/vis_type_table/public/utils/ui_state_management.test.ts
@@ -0,0 +1,52 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { PersistedState } from '../../../visualizations/public';
+import { TableUiState, uiStateManagement } from './ui_state_management';
+import { ColumnWidth, ColumnSort } from '../types';
+
+describe('uiStateManagement', () => {
+ let uiState: PersistedState;
+ let tableUiState: TableUiState;
+
+ beforeEach(() => {
+ uiState = ({
+ get: jest.fn(),
+ set: jest.fn(),
+ emit: jest.fn(),
+ } as unknown) as PersistedState;
+ tableUiState = uiStateManagement(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 = uiStateManagement(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', () => {
+ const newWidth: ColumnWidth = { colIndex: 1, width: 150 };
+ tableUiState.setWidth(newWidth);
+
+ expect(uiState.set).toHaveBeenCalledWith('vis.columnsWidth', [newWidth]);
+ expect(uiState.emit).toHaveBeenCalledWith('reload');
+ });
+});
diff --git a/src/plugins/vis_type_table/public/utils/ui_state_management.ts b/src/plugins/vis_type_table/public/utils/ui_state_management.ts
new file mode 100644
index 000000000000..20c0994f3a24
--- /dev/null
+++ b/src/plugins/vis_type_table/public/utils/ui_state_management.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 uiStateManagement(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/use_pagination.test.ts b/src/plugins/vis_type_table/public/utils/use_pagination.test.ts
new file mode 100644
index 000000000000..b6cdadde976b
--- /dev/null
+++ b/src/plugins/vis_type_table/public/utils/use_pagination.test.ts
@@ -0,0 +1,79 @@
+/*
+ * 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', () => {
+ visParams.perPage = '';
+ const { result } = renderHook(() => usePagination(visParams, 20));
+ expect(result.current).toEqual(undefined);
+ });
+
+ it('should init pagination', () => {
+ visParams.perPage = 10;
+ const { result } = renderHook(() => usePagination(visParams, 20));
+ expect(result.current).toEqual({
+ pageIndex: 0,
+ pageSize: 10,
+ 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 45dbed2c0da8..ef0e98b7cfb0 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: visConfig.perPage || 10,
+ pageSize: visParams.perPage || 0,
});
const onChangeItemsPerPage = useCallback(
(pageSize) => setPagination((p) => ({ ...p, pageSize, pageIndex: 0 })),
@@ -20,20 +20,23 @@ export const usePagination = (visConfig: TableVisConfig, nRow: number) => {
]);
useEffect(() => {
- const perPage = visConfig.perPage || 10;
+ const perPage = visParams.perPage || 0;
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]
);
};