You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ju...@apache.org on 2023/10/17 18:03:42 UTC
[superset] branch master updated: feat(sqllab): ResultTable extension (#25423)
This is an automated email from the ASF dual-hosted git repository.
justinpark pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new deef3b04eb feat(sqllab): ResultTable extension (#25423)
deef3b04eb is described below
commit deef3b04ebed1178259af5909779392cfa0cd630
Author: JUST.in DO IT <ju...@airbnb.com>
AuthorDate: Tue Oct 17 14:03:35 2023 -0400
feat(sqllab): ResultTable extension (#25423)
---
.../superset-ui-core/src/ui-overrides/types.ts | 10 ++
.../src/SqlLab/components/ResultSet/index.tsx | 9 +-
.../FilterableTable/FilterableTable.test.tsx | 20 +--
.../src/components/FilterableTable/index.tsx | 136 +++------------------
.../FilterableTable/useCellContentParser.test.ts | 58 +++++++++
.../FilterableTable/useCellContentParser.ts | 69 +++++++++++
.../src/components/FilterableTable/utils.test.tsx | 79 ++++++++++++
.../src/components/FilterableTable/utils.tsx | 59 +++++++++
.../src/components/JsonModal/JsonModal.test.tsx | 60 +++++++++
.../src/components/JsonModal/index.tsx | 112 +++++++++++++++++
10 files changed, 470 insertions(+), 142 deletions(-)
diff --git a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
index 3f0a559297..0e7e0c9783 100644
--- a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
+++ b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
@@ -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>;
}>;
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
index e16fc569e6..35eac78044 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
@@ -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}
diff --git a/superset-frontend/src/components/FilterableTable/FilterableTable.test.tsx b/superset-frontend/src/components/FilterableTable/FilterableTable.test.tsx
index aebf2c44b2..3cc04f8c25 100644
--- a/superset-frontend/src/components/FilterableTable/FilterableTable.test.tsx
+++ b/superset-frontend/src/components/FilterableTable/FilterableTable.test.tsx
@@ -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',
- );
-});
diff --git a/superset-frontend/src/components/FilterableTable/index.tsx b/superset-frontend/src/components/FilterableTable/index.tsx
index 9861d0af58..d89a65ad32 100644
--- a/superset-frontend/src/components/FilterableTable/index.tsx
+++ b/superset-frontend/src/components/FilterableTable/index.tsx
@@ -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 (
diff --git a/superset-frontend/src/components/FilterableTable/useCellContentParser.test.ts b/superset-frontend/src/components/FilterableTable/useCellContentParser.test.ts
new file mode 100644
index 0000000000..7851dd093b
--- /dev/null
+++ b/superset-frontend/src/components/FilterableTable/useCellContentParser.test.ts
@@ -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('{…}');
+});
diff --git a/superset-frontend/src/components/FilterableTable/useCellContentParser.ts b/superset-frontend/src/components/FilterableTable/useCellContentParser.ts
new file mode 100644
index 0000000000..3724691c7c
--- /dev/null
+++ b/superset-frontend/src/components/FilterableTable/useCellContentParser.ts
@@ -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],
+ );
+}
diff --git a/superset-frontend/src/components/FilterableTable/utils.test.tsx b/superset-frontend/src/components/FilterableTable/utils.test.tsx
new file mode 100644
index 0000000000..9d81b2cdc7
--- /dev/null
+++ b/superset-frontend/src/components/FilterableTable/utils.test.tsx
@@ -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');
+});
diff --git a/superset-frontend/src/components/FilterableTable/utils.tsx b/superset-frontend/src/components/FilterableTable/utils.tsx
new file mode 100644
index 0000000000..45bab99cc0
--- /dev/null
+++ b/superset-frontend/src/components/FilterableTable/utils.tsx
@@ -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;
+};
diff --git a/superset-frontend/src/components/JsonModal/JsonModal.test.tsx b/superset-frontend/src/components/JsonModal/JsonModal.test.tsx
new file mode 100644
index 0000000000..98e8db463d
--- /dev/null
+++ b/superset-frontend/src/components/JsonModal/JsonModal.test.tsx
@@ -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',
+ );
+});
diff --git a/superset-frontend/src/components/JsonModal/index.tsx b/superset-frontend/src/components/JsonModal/index.tsx
new file mode 100644
index 0000000000..c0a541a42f
--- /dev/null
+++ b/superset-frontend/src/components/JsonModal/index.tsx
@@ -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;