Skip to content

Commit

Permalink
feat(plugin-chart-table): Implement showing totals (apache#1034)
Browse files Browse the repository at this point in the history
* feat(plugin-chart-table): implement totals row

* Fix typo

* Fix totals with percentage metrics

* Code review fixes

* Use dnd with percentage metrics and sortby controls

* Make totals checkbox tooltip more descriptive

* Remove console.log

* Change totals tooltip

* Fix typing error

* Use array destructuring

* Fix typo
  • Loading branch information
kgabryje authored and zhaoyongjie committed Nov 26, 2021
1 parent 6290690 commit f4eeebf
Show file tree
Hide file tree
Showing 13 changed files with 228 additions and 91 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,15 @@ export const dnd_adhoc_metric: SharedControlConfig<'DndMetricSelect'> = {
description: t('Metric'),
default: (c: Control) => mainMetric(c.savedMetrics),
};

export const dnd_timeseries_limit_metric: SharedControlConfig<'DndMetricSelect'> = {
type: 'DndMetricSelect',
label: t('Sort by'),
default: null,
description: t('Metric used to define the top series'),
mapStateToProps: ({ datasource }) => ({
columns: datasource?.columns || [],
savedMetrics: datasource?.metrics || [],
datasourceType: datasource?.type,
}),
};
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
dnd_adhoc_filters,
dnd_adhoc_metric,
dnd_adhoc_metrics,
dnd_timeseries_limit_metric,
dndColumnsControl,
dndEntity,
dndGroupByControl,
Expand Down Expand Up @@ -480,7 +481,7 @@ const sharedControls = {
time_range,
row_limit,
limit,
timeseries_limit_metric,
timeseries_limit_metric: enableExploreDnd ? dnd_timeseries_limit_metric : timeseries_limit_metric,
series: enableExploreDnd ? dndSeries : series,
entity: enableExploreDnd ? dndEntity : entity,
x,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@
"@emotion/core": "^10.0.28",
"@superset-ui/chart-controls": "0.17.27",
"@superset-ui/core": "0.17.27",
"@types/d3-array": "^2.0.0",
"@types/react-table": "^7.0.19",
"@types/d3-array": "^2.9.0",
"@types/react-table": "^7.0.29",
"d3-array": "^2.4.0",
"match-sorter": "^6.1.0",
"match-sorter": "^6.3.0",
"memoize-one": "^5.1.1",
"react-table": "^7.2.1",
"regenerator-runtime": "^0.13.5",
"xss": "^1.0.6"
"react-table": "^7.6.3",
"regenerator-runtime": "^0.13.7",
"xss": "^1.0.8"
},
"peerDependencies": {
"@types/react": "*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
IdType,
Row,
} from 'react-table';
import { t } from '@superset-ui/core';
import { matchSorter, rankings } from 'match-sorter';
import GlobalFilter, { GlobalFilterProps } from './components/GlobalFilter';
import SelectPageSize, { SelectPageSizeProps, SizeOption } from './components/SelectPageSize';
Expand All @@ -44,6 +45,8 @@ import { PAGE_SIZE_OPTIONS } from '../consts';

export interface DataTableProps<D extends object> extends TableOptions<D> {
tableClassName?: string;
totals?: { value: string; className?: string }[];
totalsHeaderSpan?: number;
searchInput?: boolean | GlobalFilterProps<D>['searchInput'];
selectPageSize?: boolean | SelectPageSizeProps['selectRenderer'];
pageSizeOptions?: SizeOption[]; // available page size options
Expand All @@ -70,6 +73,8 @@ export default function DataTable<D extends object>({
tableClassName,
columns,
data,
totals,
totalsHeaderSpan,
serverPaginationData,
width: initialWidth = '100%',
height: initialHeight = 300,
Expand Down Expand Up @@ -229,6 +234,16 @@ export default function DataTable<D extends object>({
</tr>
)}
</tbody>
{totals && (
<tfoot>
<tr key="totals" className="dt-totals">
<td colSpan={totalsHeaderSpan}>{t('Totals')}</td>
{totals.map(item => (
<td className={item.className}>{item.value}</td>
))}
</tr>
</tfoot>
)}
</table>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ type TrWithTh = ReactElementWithChildren<'tr', Th[]>;
type TrWithTd = ReactElementWithChildren<'tr', Td[]>;
type Thead = ReactElementWithChildren<'thead', TrWithTh>;
type Tbody = ReactElementWithChildren<'tbody', TrWithTd>;
type Tfoot = ReactElementWithChildren<'tfoot', TrWithTd>;
type Col = ReactElementWithChildren<'col', null>;
type ColGroup = ReactElementWithChildren<'colgroup', Col>;

export type Table = ReactElementWithChildren<'table', (Thead | Tbody | ColGroup)[]>;
export type Table = ReactElementWithChildren<'table', (Thead | Tbody | Tfoot | ColGroup)[]>;
export type TableRenderer = () => Table;
export type GetTableSize = () => Partial<StickyState> | undefined;
export type SetStickyState = (size?: Partial<StickyState>) => void;
Expand Down Expand Up @@ -118,11 +119,18 @@ function StickyWrap({
}
let thead: Thead | undefined;
let tbody: Tbody | undefined;
let tfoot: Tfoot | undefined;

React.Children.forEach(table.props.children, node => {
if (!node) {
return;
}
if (node.type === 'thead') {
thead = node;
} else if (node.type === 'tbody') {
tbody = node;
} else if (node.type === 'tfoot') {
tfoot = node;
}
});
if (!thead || !tbody) {
Expand All @@ -134,7 +142,9 @@ function StickyWrap({
}, [thead]);

const theadRef = useRef<HTMLTableSectionElement>(null); // original thead for layout computation
const tfootRef = useRef<HTMLTableSectionElement>(null); // original tfoot for layout computation
const scrollHeaderRef = useRef<HTMLDivElement>(null); // fixed header
const scrollFooterRef = useRef<HTMLDivElement>(null); // fixed footer
const scrollBodyRef = useRef<HTMLDivElement>(null); // main body

const scrollBarSize = getScrollBarSize();
Expand All @@ -147,47 +157,51 @@ function StickyWrap({

// update scrollable area and header column sizes when mounted
useLayoutEffect(() => {
if (theadRef.current) {
const bodyThead = theadRef.current;
const theadHeight = bodyThead.clientHeight;
if (!theadHeight) {
return;
}
const fullTableHeight = (bodyThead.parentNode as HTMLTableElement).clientHeight;
const ths = bodyThead.childNodes[0].childNodes as NodeListOf<HTMLTableHeaderCellElement>;
const widths = Array.from(ths).map(th => th.clientWidth);
const [hasVerticalScroll, hasHorizontalScroll] = needScrollBar({
width: maxWidth,
height: maxHeight - theadHeight,
innerHeight: fullTableHeight,
innerWidth: widths.reduce(sum),
scrollBarSize,
});
// real container height, include table header and space for
// horizontal scroll bar
const realHeight = Math.min(
maxHeight,
hasHorizontalScroll ? fullTableHeight + scrollBarSize : fullTableHeight,
);
setStickyState({
hasVerticalScroll,
hasHorizontalScroll,
setStickyState,
width: maxWidth,
height: maxHeight,
realHeight,
tableHeight: fullTableHeight,
bodyHeight: realHeight - theadHeight,
columnWidths: widths,
});
if (!theadRef.current) {
return;
}
const bodyThead = theadRef.current;
const theadHeight = bodyThead.clientHeight;
const tfootHeight = tfootRef.current ? tfootRef.current.clientHeight : 0;
if (!theadHeight) {
return;
}
const fullTableHeight = (bodyThead.parentNode as HTMLTableElement).clientHeight;
const ths = bodyThead.childNodes[0].childNodes as NodeListOf<HTMLTableHeaderCellElement>;
const widths = Array.from(ths).map(th => th.clientWidth);
const [hasVerticalScroll, hasHorizontalScroll] = needScrollBar({
width: maxWidth,
height: maxHeight - theadHeight - tfootHeight,
innerHeight: fullTableHeight,
innerWidth: widths.reduce(sum),
scrollBarSize,
});
// real container height, include table header, footer and space for
// horizontal scroll bar
const realHeight = Math.min(
maxHeight,
hasHorizontalScroll ? fullTableHeight + scrollBarSize : fullTableHeight,
);
setStickyState({
hasVerticalScroll,
hasHorizontalScroll,
setStickyState,
width: maxWidth,
height: maxHeight,
realHeight,
tableHeight: fullTableHeight,
bodyHeight: realHeight - theadHeight - tfootHeight,
columnWidths: widths,
});
}, [maxWidth, maxHeight, setStickyState, scrollBarSize]);

let sizerTable: ReactElement | undefined;
let headerTable: ReactElement | undefined;
let footerTable: ReactElement | undefined;
let bodyTable: ReactElement | undefined;
if (needSizer) {
const theadWithRef = React.cloneElement(thead, { ref: theadRef });
const tfootWithRef = tfoot && React.cloneElement(tfoot, { ref: tfootRef });
sizerTable = (
<div
key="sizer"
Expand All @@ -197,7 +211,7 @@ function StickyWrap({
visibility: 'hidden',
}}
>
{React.cloneElement(table, {}, theadWithRef, tbody)}
{React.cloneElement(table, {}, theadWithRef, tbody, tfootWithRef)}
</div>
);
}
Expand Down Expand Up @@ -242,10 +256,26 @@ function StickyWrap({
</div>
);

footerTable = tfoot && (
<div
key="footer"
ref={scrollFooterRef}
style={{
overflow: 'hidden',
}}
>
{React.cloneElement(table, mergeStyleProp(table, fixedTableLayout), headerColgroup, tfoot)}
{footerTable}
</div>
);

const onScroll: UIEventHandler<HTMLDivElement> = e => {
if (scrollHeaderRef.current) {
scrollHeaderRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
if (scrollFooterRef.current) {
scrollFooterRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
};
bodyTable = (
<div
Expand All @@ -272,6 +302,7 @@ function StickyWrap({
>
{headerTable}
{bodyTable}
{footerTable}
{sizerTable}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export default styled.div`
.dt-metric {
text-align: right;
}
.dt-totals {
font-weight: bold;
}
.dt-is-null {
color: ${({ theme: { colors } }) => colors.grayscale.light1};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, useMemo, useCallback, CSSProperties } from 'react';
import { ColumnInstance, DefaultSortTypes, ColumnWithLooseAccessor } from 'react-table';
import React, { CSSProperties, useCallback, useMemo, useState } from 'react';
import { ColumnInstance, ColumnWithLooseAccessor, DefaultSortTypes } from 'react-table';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import { FaSort, FaSortUp as FaSortAsc, FaSortDown as FaSortDesc } from 'react-icons/fa';
import {
t,
tn,
DataRecordValue,
DataRecord,
GenericDataType,
getNumberFormatter,
} from '@superset-ui/core';
import { FaSort, FaSortDown as FaSortDesc, FaSortUp as FaSortAsc } from 'react-icons/fa';
import { DataRecord, DataRecordValue, GenericDataType, t, tn } from '@superset-ui/core';

import { TableChartTransformedProps, DataColumnMeta } from './types';
import { DataColumnMeta, TableChartTransformedProps } from './types';
import DataTable, {
DataTableProps,
SearchInputProps,
Expand All @@ -38,7 +31,7 @@ import DataTable, {
} from './DataTable';

import Styles from './Styles';
import formatValue from './utils/formatValue';
import { formatColumnValue } from './utils/formatValue';
import { PAGE_SIZE_OPTIONS } from './consts';
import { updateExternalFormData } from './DataTable/utils/externalAPIs';

Expand Down Expand Up @@ -154,6 +147,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
height,
width,
data,
totals,
isRawRecords,
rowCount = 0,
columns: columnsMeta,
Expand Down Expand Up @@ -220,7 +214,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(

const getColumnConfigs = useCallback(
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
const { key, label, dataType, isMetric, formatter, config = {} } = column;
const { key, label, dataType, isMetric, config = {} } = column;
const isNumber = dataType === GenericDataType.NUMERIC;
const isFilter = !isNumber && emitFilter;
const textAlign = config.horizontalAlign
Expand All @@ -241,10 +235,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
config.alignPositiveNegative === undefined ? defaultAlignPN : config.alignPositiveNegative;
const colorPositiveNegative =
config.colorPositiveNegative === undefined ? defaultColorPN : config.colorPositiveNegative;
const smallNumberFormatter =
config.d3SmallNumberFormat === undefined
? formatter
: getNumberFormatter(config.d3SmallNumberFormat);

const valueRange =
(config.showCellBars === undefined ? showCellBars : config.showCellBars) &&
Expand All @@ -263,12 +253,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// so we ask TS not to check.
accessor: ((datum: D) => datum[key]) as never,
Cell: ({ value }: { column: ColumnInstance<D>; value: DataRecordValue }) => {
const [isHtml, text] = formatValue(
isNumber && typeof value === 'number' && Math.abs(value) < 1
? smallNumberFormatter
: formatter,
value,
);
const [isHtml, text] = formatColumnValue(column, value);
const html = isHtml ? { __html: text } : undefined;
const cellProps = {
// show raw number in title in case of numeric values
Expand Down Expand Up @@ -346,10 +331,31 @@ export default function TableChart<D extends DataRecord = DataRecord>(
updateExternalFormData(setDataMask, pageNumber, pageSize);
};

const totalsFormatted =
totals &&
columnsMeta
.filter(column => Object.keys(totals).includes(column.key))
.reduce(
(acc: { value: string; className: string }[], column) => [
...acc,
{
value: formatColumnValue(column, totals[column.key])[1],
className: column.dataType === GenericDataType.NUMERIC ? 'dt-metric' : '',
},
],
[],
);

const totalsHeaderSpan =
totalsFormatted &&
columnsMeta.filter(column => !column.isPercentMetric).length - totalsFormatted.length;

return (
<Styles>
<DataTable<D>
columns={columns}
totals={totalsFormatted}
totalsHeaderSpan={totalsHeaderSpan}
data={data}
rowCount={rowCount}
tableClassName="table table-striped table-condensed"
Expand Down

0 comments on commit f4eeebf

Please sign in to comment.