feat(sqllab): ResultTable extension (#25423)

This commit is contained in:
JUST.in DO IT 2023-10-17 14:03:35 -04:00 committed by GitHub
parent 150e5c90f7
commit deef3b04eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 470 additions and 142 deletions

View File

@ -118,6 +118,15 @@ export interface SQLFormExtensionProps {
startQuery: (ctasArg?: any, ctas_method?: any) => void;
}
export interface SQLResultTableExtentionProps {
queryId: string;
orderedColumnKeys: string[];
data: Record<string, unknown>[];
height: number;
filterText?: string;
expandedColumns?: string[];
}
export type Extensions = Partial<{
'alertsreports.header.icon': React.ComponentType;
'embedded.documentation.configuration_details': React.ComponentType<ConfigDetailsProps>;
@ -137,4 +146,5 @@ export type Extensions = Partial<{
'database.delete.related': React.ComponentType<DatabaseDeleteRelatedExtensionProps>;
'dataset.delete.related': React.ComponentType<DatasetDeleteRelatedExtensionProps>;
'sqleditor.extension.form': React.ComponentType<SQLFormExtensionProps>;
'sqleditor.extension.resultTable': React.ComponentType<SQLResultTableExtentionProps>;
}>;

View File

@ -32,6 +32,7 @@ import {
usePrevious,
css,
getNumberFormatter,
getExtensionsRegistry,
} from '@superset-ui/core';
import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
import {
@ -135,6 +136,8 @@ const ResultSetButtons = styled.div`
const ROWS_CHIP_WIDTH = 100;
const GAP = 8;
const extensionsRegistry = getExtensionsRegistry();
const ResultSet = ({
cache = false,
csv = true,
@ -149,6 +152,9 @@ const ResultSet = ({
user,
defaultQueryLimit,
}: ResultSetProps) => {
const ResultTable =
extensionsRegistry.get('sqleditor.extension.resultTable') ??
FilterableTable;
const theme = useTheme();
const [searchText, setSearchText] = useState('');
const [cachedData, setCachedData] = useState<Record<string, unknown>[]>([]);
@ -578,8 +584,9 @@ const ResultSet = ({
{sql}
</>
)}
<FilterableTable
<ResultTable
data={data}
queryId={query.id}
orderedColumnKeys={results.columns.map(col => col.column_name)}
height={rowsHeight}
filterText={searchText}

View File

@ -17,9 +17,7 @@
* under the License.
*/
import React from 'react';
import FilterableTable, {
convertBigIntStrToNumber,
} from 'src/components/FilterableTable';
import FilterableTable from 'src/components/FilterableTable';
import { render, screen, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
@ -383,19 +381,3 @@ describe('FilterableTable sorting - RTL', () => {
);
});
});
test('renders bigInt value in a number format', () => {
expect(convertBigIntStrToNumber('123')).toBe('123');
expect(convertBigIntStrToNumber('some string value')).toBe(
'some string value',
);
expect(convertBigIntStrToNumber('{ a: 123 }')).toBe('{ a: 123 }');
expect(convertBigIntStrToNumber('"Not a Number"')).toBe('"Not a Number"');
// trim quotes for bigint string format
expect(convertBigIntStrToNumber('"-12345678901234567890"')).toBe(
'-12345678901234567890',
);
expect(convertBigIntStrToNumber('"12345678901234567890"')).toBe(
'12345678901234567890',
);
});

View File

@ -18,55 +18,12 @@
*/
import JSONbig from 'json-bigint';
import React, { useEffect, useRef, useState, useMemo } from 'react';
import { JSONTree } from 'react-json-tree';
import {
getMultipleTextDimensions,
t,
safeHtmlSpan,
styled,
} from '@superset-ui/core';
import { getMultipleTextDimensions, styled } from '@superset-ui/core';
import { useDebounceValue } from 'src/hooks/useDebounceValue';
import { useJsonTreeTheme } from 'src/hooks/useJsonTreeTheme';
import Button from '../Button';
import CopyToClipboard from '../CopyToClipboard';
import ModalTrigger from '../ModalTrigger';
import { useCellContentParser } from './useCellContentParser';
import { renderResultCell } from './utils';
import { Table, TableSize } from '../Table';
function safeJsonObjectParse(
data: unknown,
): null | unknown[] | Record<string, unknown> {
// First perform a cheap proxy to avoid calling JSON.parse on data that is clearly not a
// JSON object or array
if (
typeof data !== 'string' ||
['{', '['].indexOf(data.substring(0, 1)) === -1
) {
return null;
}
// We know `data` is a string starting with '{' or '[', so try to parse it as a valid object
try {
const jsonData = JSONbig({ storeAsString: true }).parse(data);
if (jsonData && typeof jsonData === 'object') {
return jsonData;
}
return null;
} catch (_) {
return null;
}
}
export function convertBigIntStrToNumber(value: string | number) {
if (typeof value === 'string' && /^"-?\d+"$/.test(value)) {
return value.substring(1, value.length - 1);
}
return value;
}
function renderBigIntStrToNumber(value: string | number) {
return <>{convertBigIntStrToNumber(value)}</>;
}
const SCROLL_BAR_HEIGHT = 15;
// This regex handles all possible number formats in javascript, including ints, floats,
// exponential notation, NaN, and Infinity.
@ -147,43 +104,10 @@ const FilterableTable = ({
const [fitted, setFitted] = useState(false);
const [list] = useState<Datum[]>(() => formatTableData(data));
// columns that have complex type and were expanded into sub columns
const complexColumns = useMemo<Record<string, boolean>>(
() =>
orderedColumnKeys.reduce(
(obj, key) => ({
...obj,
[key]: expandedColumns.some(name => name.startsWith(`${key}.`)),
}),
{},
),
[expandedColumns, orderedColumnKeys],
);
const getCellContent = ({
cellData,
columnKey,
}: {
cellData: CellDataType;
columnKey: string;
}) => {
if (cellData === null) {
return 'NULL';
}
const content = String(cellData);
const firstCharacter = content.substring(0, 1);
let truncated;
if (firstCharacter === '[') {
truncated = '[…]';
} else if (firstCharacter === '{') {
truncated = '{…}';
} else {
truncated = '';
}
return complexColumns[columnKey] ? truncated : content;
};
const jsonTreeTheme = useJsonTreeTheme();
const getCellContent = useCellContentParser({
columnKeys: orderedColumnKeys,
expandedColumns,
});
const getWidthsForColumns = () => {
const PADDING = 50; // accounts for cell padding and width of sorting icon
@ -259,29 +183,6 @@ const FilterableTable = ({
return values.some(v => v.includes(lowerCaseText));
};
const renderJsonModal = (
node: React.ReactNode,
jsonObject: Record<string, unknown> | unknown[],
jsonString: CellDataType,
) => (
<ModalTrigger
modalBody={
<JSONTree
data={jsonObject}
theme={jsonTreeTheme}
valueRenderer={renderBigIntStrToNumber}
/>
}
modalFooter={
<Button>
<CopyToClipboard shouldShowText={false} text={jsonString} />
</Button>
}
modalTitle={t('Cell content')}
triggerNode={node}
/>
);
// Parse any numbers from strings so they'll sort correctly
const parseNumberFromString = (value: string | number | null) => {
if (typeof value === 'string') {
@ -321,21 +222,6 @@ const FilterableTable = ({
[list, keyword],
);
const renderTableCell = (cellData: CellDataType, columnKey: string) => {
const cellNode = getCellContent({ cellData, columnKey });
if (cellData === null) {
return <i className="text-muted">{cellNode}</i>;
}
const jsonObject = safeJsonObjectParse(cellData);
if (jsonObject) {
return renderJsonModal(cellNode, jsonObject, cellData);
}
if (allowHTML && typeof cellData === 'string') {
return safeHtmlSpan(cellNode);
}
return cellNode;
};
// exclude the height of the horizontal scroll bar from the height of the table
// and the height of the table container if the content overflows
const totalTableHeight =
@ -349,7 +235,13 @@ const FilterableTable = ({
dataIndex: key,
width: widthsForColumnsByKey[key],
sorter: (a: Datum, b: Datum) => sortResults(key, a, b),
render: (text: CellDataType) => renderTableCell(text, key),
render: (text: CellDataType) =>
renderResultCell({
cellData: text,
columnKey: key,
allowHTML,
getCellContent,
}),
}));
return (

View File

@ -0,0 +1,58 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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 { renderHook } from '@testing-library/react-hooks';
import { useCellContentParser } from './useCellContentParser';
test('should return NULL for null cell data', () => {
const { result } = renderHook(() =>
useCellContentParser({ columnKeys: [], expandedColumns: [] }),
);
const parser = result.current;
expect(parser({ cellData: null, columnKey: '' })).toBe('NULL');
});
test('should return truncated string for complex columns', () => {
const { result } = renderHook(() =>
useCellContentParser({
columnKeys: ['a'],
expandedColumns: ['a.b'],
}),
);
const parser = result.current;
expect(
parser({
cellData: 'this is a very long string',
columnKey: 'a.b',
}),
).toBe('this is a very long string');
expect(
parser({
cellData: '["this is a very long string"]',
columnKey: 'a',
}),
).toBe('[…]');
expect(
parser({
cellData: '{ "b": "this is a very long string" }',
columnKey: 'a',
}),
).toBe('{…}');
});

View File

@ -0,0 +1,69 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useMemo } from 'react';
export type CellDataType = string | number | null;
export const NULL_STRING = 'NULL';
type Params = {
columnKeys: string[];
expandedColumns?: string[];
};
export function useCellContentParser({ columnKeys, expandedColumns }: Params) {
// columns that have complex type and were expanded into sub columns
const complexColumns = useMemo<Record<string, boolean>>(
() =>
columnKeys.reduce(
(obj, key) => ({
...obj,
[key]: expandedColumns?.some(name => name.startsWith(`${key}.`)),
}),
{},
),
[expandedColumns, columnKeys],
);
return useCallback(
({
cellData,
columnKey,
}: {
cellData: CellDataType;
columnKey: string;
}) => {
if (cellData === null) {
return NULL_STRING;
}
const content = String(cellData);
const firstCharacter = content.substring(0, 1);
let truncated;
if (firstCharacter === '[') {
truncated = '[…]';
} else if (firstCharacter === '{') {
truncated = '{…}';
} else {
truncated = '';
}
return complexColumns[columnKey] ? truncated : content;
},
[complexColumns],
);
}

View File

@ -0,0 +1,79 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { render } from 'spec/helpers/testing-library';
import { renderResultCell } from './utils';
jest.mock('src/components/JsonModal', () => ({
...jest.requireActual('src/components/JsonModal'),
default: () => <div data-test="mock-json-modal" />,
}));
const unexpectedGetCellContent = () => 'none';
test('should render NULL for null cell data', () => {
const { container } = render(
<>
{renderResultCell({
cellData: null,
columnKey: 'column1',
getCellContent: unexpectedGetCellContent,
})}
</>,
);
expect(container).toHaveTextContent('NULL');
});
test('should render JsonModal for json cell data', () => {
const { getByTestId } = render(
<>
{renderResultCell({
cellData: '{ "a": 1 }',
columnKey: 'a',
getCellContent: unexpectedGetCellContent,
})}
</>,
);
expect(getByTestId('mock-json-modal')).toBeInTheDocument();
});
test('should render cellData value for default cell data', () => {
const { container } = render(
<>
{renderResultCell({
cellData: 'regular_text',
columnKey: 'a',
})}
</>,
);
expect(container).toHaveTextContent('regular_text');
});
test('should transform cell data by getCellContent for the regular text', () => {
const { container } = render(
<>
{renderResultCell({
cellData: 'regular_text',
columnKey: 'a',
getCellContent: ({ cellData, columnKey }) => `${cellData}:${columnKey}`,
})}
</>,
);
expect(container).toHaveTextContent('regular_text:a');
});

View File

@ -0,0 +1,59 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import JsonModal, { safeJsonObjectParse } from 'src/components/JsonModal';
import { t, safeHtmlSpan } from '@superset-ui/core';
import { NULL_STRING, CellDataType } from './useCellContentParser';
type CellParams = {
cellData: CellDataType;
columnKey: string;
};
type Params = CellParams & {
allowHTML?: boolean;
getCellContent?: (args: CellParams) => string;
};
export const renderResultCell = ({
cellData,
getCellContent,
columnKey,
allowHTML = true,
}: Params) => {
const cellNode =
getCellContent?.({ cellData, columnKey }) ?? String(cellData);
if (cellData === null) {
return <i className="text-muted">{NULL_STRING}</i>;
}
const jsonObject = safeJsonObjectParse(cellData);
if (jsonObject) {
return (
<JsonModal
modalTitle={t('Cell content')}
jsonObject={jsonObject}
jsonValue={cellData}
/>
);
}
if (allowHTML && typeof cellData === 'string') {
return safeHtmlSpan(cellNode);
}
return cellNode;
};

View File

@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { fireEvent, render } from 'spec/helpers/testing-library';
import JsonModal, { convertBigIntStrToNumber } from '.';
jest.mock('react-json-tree', () => ({
JSONTree: () => <div data-test="mock-json-tree" />,
}));
test('renders JSON object in a tree view in a modal', () => {
const jsonData = { a: 1 };
const jsonValue = JSON.stringify(jsonData);
const { getByText, getByTestId, queryByTestId } = render(
<JsonModal
jsonObject={jsonData}
jsonValue={jsonValue}
modalTitle="title"
/>,
{
useRedux: true,
},
);
expect(queryByTestId('mock-json-tree')).not.toBeInTheDocument();
const link = getByText(jsonValue);
fireEvent.click(link);
expect(getByTestId('mock-json-tree')).toBeInTheDocument();
});
test('renders bigInt value in a number format', () => {
expect(convertBigIntStrToNumber('123')).toBe('123');
expect(convertBigIntStrToNumber('some string value')).toBe(
'some string value',
);
expect(convertBigIntStrToNumber('{ a: 123 }')).toBe('{ a: 123 }');
expect(convertBigIntStrToNumber('"Not a Number"')).toBe('"Not a Number"');
// trim quotes for bigint string format
expect(convertBigIntStrToNumber('"-12345678901234567890"')).toBe(
'-12345678901234567890',
);
expect(convertBigIntStrToNumber('"12345678901234567890"')).toBe(
'12345678901234567890',
);
});

View File

@ -0,0 +1,112 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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.
*/
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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 JSONbig from 'json-bigint';
import React from 'react';
import { JSONTree } from 'react-json-tree';
import { useJsonTreeTheme } from 'src/hooks/useJsonTreeTheme';
import Button from '../Button';
import CopyToClipboard from '../CopyToClipboard';
import ModalTrigger from '../ModalTrigger';
export function safeJsonObjectParse(
data: unknown,
): null | unknown[] | Record<string, unknown> {
// First perform a cheap proxy to avoid calling JSON.parse on data that is clearly not a
// JSON object or array
if (
typeof data !== 'string' ||
['{', '['].indexOf(data.substring(0, 1)) === -1
) {
return null;
}
// We know `data` is a string starting with '{' or '[', so try to parse it as a valid object
try {
const jsonData = JSONbig({ storeAsString: true }).parse(data);
if (jsonData && typeof jsonData === 'object') {
return jsonData;
}
return null;
} catch (_) {
return null;
}
}
export function convertBigIntStrToNumber(value: string | number) {
if (typeof value === 'string' && /^"-?\d+"$/.test(value)) {
return value.substring(1, value.length - 1);
}
return value;
}
function renderBigIntStrToNumber(value: string | number) {
return <>{convertBigIntStrToNumber(value)}</>;
}
type CellDataType = string | number | null;
export interface Props {
modalTitle: string;
jsonObject: Record<string, unknown> | unknown[];
jsonValue: CellDataType;
}
const JsonModal: React.FC<Props> = ({ modalTitle, jsonObject, jsonValue }) => {
const jsonTreeTheme = useJsonTreeTheme();
return (
<ModalTrigger
modalBody={
<JSONTree
data={jsonObject}
theme={jsonTreeTheme}
valueRenderer={renderBigIntStrToNumber}
/>
}
modalFooter={
<Button>
<CopyToClipboard shouldShowText={false} text={jsonValue} />
</Button>
}
modalTitle={modalTitle}
triggerNode={<>{jsonValue}</>}
/>
);
};
export default JsonModal;