Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Table Visualization]restore export csv feature to table vis #2568

Merged
merged 2 commits into from
Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/plugins/vis_type_table/opensearch_dashboards.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"opensearchDashboardsUtils",
"opensearchDashboardsReact",
"charts",
"share",
"visDefaultEditor"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@ import { TableVisConfig, ColumnWidth, SortColumn } from '../types';
import { getDataGridColumns } from './table_vis_grid_columns';
import { usePagination } from '../utils';
import { convertToFormattedData } from '../utils/convert_to_formatted_data';
import { TableVisControl } from './table_vis_control';

interface TableVisComponentProps {
title?: string;
table: Table;
visConfig: TableVisConfig;
handlers: IInterpreterRenderHandlers;
}

export const TableVisComponent = ({ table, visConfig, handlers }: TableVisComponentProps) => {
export const TableVisComponent = ({
title,
table,
visConfig,
handlers,
}: TableVisComponentProps) => {
const { formattedRows: rows, formattedColumns: columns } = convertToFormattedData(
table,
visConfig
Expand Down Expand Up @@ -103,6 +110,8 @@ export const TableVisComponent = ({ table, visConfig, handlers }: TableVisCompon
[columns, currentColState, handlers.uiState]
);

const ariaLabel = title || visConfig.title || 'tableVis';

const footerCellValue = visConfig.showTotal
? // @ts-expect-error
({ columnId }) => {
Expand All @@ -113,7 +122,7 @@ export const TableVisComponent = ({ table, visConfig, handlers }: TableVisCompon

return (
<EuiDataGrid
aria-label="tableVis"
aria-label={ariaLabel}
columns={dataGridColumns}
columnVisibility={{
visibleColumns: columns.map(({ id }) => id),
Expand All @@ -135,6 +144,9 @@ export const TableVisComponent = ({ table, visConfig, handlers }: TableVisCompon
showSortSelector: false,
showFullScreenSelector: false,
showStyleSelector: false,
additionalControls: (
<TableVisControl filename={visConfig.title} rows={sortedRows} columns={columns} />
),
}}
/>
);
Expand Down
57 changes: 57 additions & 0 deletions src/plugins/vis_type_table/public/components/table_vis_control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useState } from 'react';
import { EuiPopover, EuiButtonEmpty, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { OpenSearchDashboardsDatatableRow } from 'src/plugins/expressions';
import { CoreStart } from 'opensearch-dashboards/public';
import { exportAsCsv } from '../utils/convert_to_csv_data';
import { FormattedColumn } from '../types';
import { useOpenSearchDashboards } from '../../../../../src/plugins/opensearch_dashboards_react/public';

interface TableVisControlProps {
filename?: string;
rows: OpenSearchDashboardsDatatableRow[];
columns: FormattedColumn[];
}

export const TableVisControl = (props: TableVisControlProps) => {
const {
services: { uiSettings },
} = useOpenSearchDashboards<CoreStart>();
const [isPopoverOpen, setPopover] = useState(false);

return (
<EuiPopover
id="dataTableExportData"
button={
<EuiButtonEmpty size="xs" iconType="download" onClick={() => setPopover((open) => !open)}>
Export
</EuiButtonEmpty>
}
isOpen={isPopoverOpen}
closePopover={() => setPopover(false)}
panelPaddingSize="none"
>
<EuiContextMenuPanel
size="s"
items={[
<EuiContextMenuItem
key="rawCsv"
onClick={() => exportAsCsv(false, { ...props, uiSettings })}
>
Raw
</EuiContextMenuItem>,
<EuiContextMenuItem
key="formattedCsv"
onClick={() => exportAsCsv(true, { ...props, uiSettings })}
>
Formatted
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
);
};
1 change: 1 addition & 0 deletions src/plugins/vis_type_table/public/to_ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const toExpressionAst = (vis: Vis, params: any) => {
const schemas = getVisSchemas(vis, params);

const tableData = {
title: vis.title,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is visualization title always present or do we need some default title set?

Copy link
Member Author

@ananzh ananzh Oct 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good question. Here table vis is using Visualization plugin. title is initiated here

public title: string = '';

when table vis is saved, title will be the saved title. otherwise, it will be an empty string.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only place requires initialization is buckets: schemas.bucket || []. This is because for table vis, we defined buckets as a required field in TableVisConfig:

export interface TableVisConfig extends TableVisParams {
  title: string;
  metrics: SchemaConfig[];
  buckets: SchemaConfig[];
  splitRow?: SchemaConfig[];
  splitColumn?: SchemaConfig[];
}

But in Visualization plugin, bucket is defined as any[] || undefined since it is optional, in Schema. Therefore, we need to initiated to [].

export interface Schemas {
  metric: SchemaConfig[];
  bucket?: any[];
  geo_centroid?: any[];
  group?: any[];
  params?: any[];
  radius?: any[];
  segment?: any[];
  split_column?: any[];
  split_row?: any[];
  width?: any[];
  // catch all for schema name
  [key: string]: any[] | undefined;
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SchemaConfig

I see! Is there a specific reason as to why buckets is a required field in Table vis?

metrics: schemas.metric,
buckets: schemas.bucket || [],
splitRow: schemas.split_row,
Expand Down
1 change: 1 addition & 0 deletions src/plugins/vis_type_table/public/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export enum AggTypes {
}

export interface TableVisConfig extends TableVisParams {
title: string;
metrics: SchemaConfig[];
buckets: SchemaConfig[];
splitRow?: SchemaConfig[];
Expand Down
85 changes: 85 additions & 0 deletions src/plugins/vis_type_table/public/utils/convert_to_csv_data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
* Any modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/

/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { isObject } from 'lodash';
// @ts-ignore
import { saveAs } from '@elastic/filesaver';
import { CoreStart } from 'opensearch-dashboards/public';
import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../../../share/public';
import { OpenSearchDashboardsDatatable } from '../../../expressions/public';
import { FormattedColumn } from '../types';

const nonAlphaNumRE = /[^a-zA-Z0-9]/;
const allDoubleQuoteRE = /"/g;

interface CSVDataProps {
filename?: string;
rows: OpenSearchDashboardsDatatable['rows'];
columns: FormattedColumn[];
uiSettings: CoreStart['uiSettings'];
}

const toCsv = function (formatted: boolean, { rows, columns, uiSettings }: CSVDataProps) {
const separator = uiSettings.get(CSV_SEPARATOR_SETTING);
const quoteValues = uiSettings.get(CSV_QUOTE_VALUES_SETTING);

function escape(val: any) {
if (!formatted && isObject(val)) val = val.valueOf();
val = String(val);
if (quoteValues && nonAlphaNumRE.test(val)) {
val = '"' + val.replace(allDoubleQuoteRE, '""') + '"';
}
return val;
}

let csvRows: string[][] = [];
for (const row of rows) {
const rowArray = [];
for (const col of columns) {
const value = row[col.id];
const formattedValue =
formatted && col.formatter ? escape(col.formatter.convert(value)) : escape(value);
rowArray.push(formattedValue);
}
csvRows = [...csvRows, rowArray];
}

// add the columns to the rows
csvRows.unshift(columns.map((col) => escape(col.title)));

return csvRows.map((row) => row.join(separator) + '\r\n').join('');
};

export const exportAsCsv = function (formatted: boolean, csvData: CSVDataProps) {
const csv = new Blob([toCsv(formatted, csvData)], { type: 'text/csv;charset=utf-8' });
const type = formatted ? 'formatted' : 'raw';
if (csvData.filename) saveAs(csv, `${csvData.filename}-${type}.csv`);
else saveAs(csv, `unsaved-${type}.csv`);
};