Skip to content
This repository has been archived by the owner on Dec 10, 2021. It is now read-only.

Commit

Permalink
fix(legacy-table): avoid React DOM (#392)
Browse files Browse the repository at this point in the history
* fix(legacy-table): avoid React DOM

`jquery.datatables` will manipulate DOMs, sometimes remove them. In case
of component being reloaded with updated props, React will not be able
to find those removed nodes, causing a `cannot removeChild` error.

Because of the the way to assign row keys, if table shape changes (add
or remove columns), React may also have difficulty match the cached
nodes via keys.

In general it's a bad idea to directly manipulate React rendered DOM
nodes, so we better just let `jquery.datatables` handle everything.

In the future, once we removed `jquery.datatables`, a pure React
component will not have such issues.

* fix(legacy-table): handle the case when percentMetrics is null

* fix(legacy-table): linting errors

* refactor: use nimbus build

* test(legacy-table): add React component tests

* test(legacy-table): more sophisticated cases

* fix: address PR #392 comments

* chore(legacy-table): clean up tests case setups

Not seeing the console.warn errors anymore. So cleaning it up.

Previously it was from `<SuperChart />` component, but since we have
updated the test case to not use <SuperChart>, we are good now.

* fix(legacy-table): misleading comment
  • Loading branch information
ktmud committed Mar 9, 2020
1 parent 596d655 commit a1a7896
Show file tree
Hide file tree
Showing 10 changed files with 324 additions and 71 deletions.
8 changes: 7 additions & 1 deletion plugins/superset-ui-plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"fast-glob": "^3.0.1",
"fs-extra": "^8.0.1",
"husky": "^4.2.1",
"identity-obj-proxy": "^3.0.0",
"jest-mock-console": "^1.0.0",
"lerna": "^3.15.0",
"lint-staged": "^10.0.7",
Expand Down Expand Up @@ -102,7 +103,8 @@
"jest": {
"timers": "real",
"setupFilesAfterEnv": [
"@airbnb/config-jest/enzyme"
"@airbnb/config-jest/enzyme",
"./scripts/setupJest.js"
],
"coverageThreshold": {
"global": {
Expand All @@ -111,6 +113,10 @@
"lines": 1,
"statements": 1
}
},
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
"\\.(css|less)$": "identity-obj-proxy"
}
},
"eslint": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@superset-ui/time-format": "^0.12.0",
"@superset-ui/translation": "^0.12.0",
"jquery": "^3.4.1",
"react": "^16.8.0"
"react": "^16.8.0",
"react-dom": "^16.8.0"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/
import { t } from '@superset-ui/translation';
import React, { useEffect, createRef } from 'react';
import ReactDOMServer from 'react-dom/server';
import { formatNumber, NumberFormats } from '@superset-ui/number-format';
import { getTimeFormatter } from '@superset-ui/time-format';
import { filterXSS } from 'xss';
Expand Down Expand Up @@ -202,49 +203,58 @@ export default function ReactDataTable(props: DataTableProps) {
};
});

return (
<div ref={rootElem} className="superset-legacy-chart-table">
<table className="table table-striped table-condensed table-hover">
<thead>
<tr>
{columns.map(col => (
// by default all columns will have sorting
<th key={col.key} className="sorting" title={col.label}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((record, i) => (
// hide rows after first page makes the initial render faster (less layout computation)
// eslint-disable-next-line react/no-array-index-key
<tr key={i} style={{ display: pageLength > 0 && i >= pageLength ? 'none' : undefined }}>
{columns.map(({ key, format }) => {
const val = record[key];
const keyIsMetric = metricsSet.has(key);
const text = cellText(key, format, val);
const isHtml = !keyIsMetric && isProbablyHTML(text);
return (
<td
key={key}
// only set innerHTML for actual html content, this saves time
dangerouslySetInnerHTML={isHtml ? { __html: text } : undefined}
data-sort={val}
className={keyIsMetric ? 'text-right' : ''}
style={{
backgroundImage: keyIsMetric ? cellBar(key, val as number) : undefined,
}}
title={keyIsMetric || percentMetricsSet.has(key) ? (val as string) : ''}
>
{isHtml ? null : text}
</td>
);
})}
</tr>
const tableElement = (
<table className="table table-striped table-condensed table-hover">
<thead>
<tr>
{columns.map(col => (
// by default all columns will have sorting
<th key={col.key} className="sorting" title={col.label}>
{col.label}
</th>
))}
</tbody>
</table>
</div>
</tr>
</thead>
<tbody>
{data.map((record, i) => (
<tr
// eslint-disable-next-line react/no-array-index-key
key={i}
// hide rows after first page makes the initial render faster (less layout computation)
style={{ display: pageLength > 0 && i >= pageLength ? 'none' : undefined }}
>
{columns.map(({ key, format }) => {
const val = record[key];
const keyIsMetric = metricsSet.has(key);
const text = cellText(key, format, val);
const isHtml = !keyIsMetric && isProbablyHTML(text);
return (
<td
key={key}
// only set innerHTML for actual html content, this saves time
dangerouslySetInnerHTML={isHtml ? { __html: text } : undefined}
data-sort={val}
className={keyIsMetric ? 'text-right' : ''}
style={{
backgroundImage: keyIsMetric ? cellBar(key, val as number) : undefined,
}}
title={keyIsMetric || percentMetricsSet.has(key) ? (val as string) : ''}
>
{isHtml ? null : text}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);

return (
<div
dangerouslySetInnerHTML={{ __html: ReactDOMServer.renderToStaticMarkup(tableElement) }}
ref={rootElem}
className="superset-legacy-chart-table"
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,11 @@ export default function transformProps(chartProps: ChartProps): DataTableProps {
} = formData;
const { columnFormats, verboseMap } = datasource;
const { records, columns: columns_ } = queryData.data;
const metrics = metrics_.map(consolidateMetricShape);
const metrics = (metrics_ ?? []).map(consolidateMetricShape);
// percent metrics always starts with a '%' sign.
const percentMetrics = percentMetrics_.map(consolidateMetricShape).map((x: string) => `%${x}`);
const percentMetrics = (percentMetrics_ ?? [])
.map(consolidateMetricShape)
.map((x: string) => `%${x}`);
const columns = columns_.map((key: string) => {
let label = verboseMap[key] || key;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* 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 { mount } from 'enzyme';
import ReactDataTable from '../src/ReactDataTable';
import transformProps from '../src/transformProps';
import testData from './testData';

describe('legacy-table', () => {
// Can test more prop transformation here. Not needed for now.
describe('transformProps', () => {});

describe('ReactDataTable', () => {
let wrap: any; // the ReactDataTable wraper

it('render basic data', () => {
wrap = mount(<ReactDataTable {...transformProps(testData.basic)} />);
const tree = wrap.render(); // returns a CheerioWrapper with jQuery-like API
const cells = tree.find('td');
expect(tree.hasClass('superset-legacy-chart-table')).toEqual(true);
expect(cells).toHaveLength(4);
expect(cells.eq(0).text()).toEqual('Michael');
expect(cells.eq(3).attr('data-sort')).toEqual('2467');
});

it('render advanced data', () => {
// should successfull rerender with new props
wrap.setProps(transformProps(testData.advanced));
const tree = wrap.render();
const cells = tree.find('td');
expect(
tree
.find('th')
.eq(1)
.text(),
).toEqual('Sum of Num');
expect(cells.eq(2).text()).toEqual('12.346%');
expect(cells.eq(4).text()).toEqual('2.47k');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* 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 { ChartProps } from '@superset-ui/chart';

const basicFormData = {
alignPn: false,
colorPn: false,
includeSearch: false,
orderDesc: true,
pageLength: 0,
metrics: [],
percentMetrics: null,
timeseriesLimitMetric: null,
tableFilter: false,
tableTimestampFormat: '%Y-%m-%d %H:%M:%S',
};

const basicChartProps = {
width: 200,
height: 500,
annotationData: {},
datasource: {
columnFormats: {},
verboseMap: {},
},
rawDatasource: {},
rawFormData: {},
hooks: {},
initialValues: {},
queryData: {
data: {
columns: [],
records: [],
},
},
formData: basicFormData,
};

/**
* Basic data input
*/
const basic: ChartProps = {
...basicChartProps,
queryData: {
data: {
columns: ['name', 'sum__num'],
records: [
{
name: 'Michael',
sum__num: 2467063,
'%pct_nice': 0.123456,
},
{
name: 'Joe',
sum__num: 2467,
'%pct_nice': 0.00001,
},
],
},
},
};

/**
* Advanced data input with
* - verbose map
* - metric columns
*/
const advanced: ChartProps = {
...basic,
datasource: {
columnFormats: {},
verboseMap: {
sum__num: 'Sum of Num',
},
},
formData: {
...basicFormData,
metrics: ['sum__num'],
percentMetrics: ['pct_nice'],
},
queryData: {
data: {
columns: ['name', 'sum__num', '%pct_nice'],
records: [...basic.queryData.data.records],
},
},
};

export default {
basic,
advanced,
};
Loading

0 comments on commit a1a7896

Please sign in to comment.