feat(table): Table with Time Comparison (#28057)

Co-authored-by: Lily Kuang <lily@preset.io>
Co-authored-by: lilykuang <jialikuang@gmail.com>
Co-authored-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
This commit is contained in:
Antonio Rivero
2024-06-14 20:21:43 +02:00
committed by GitHub
parent 37753cbdc2
commit 7ddea62331
36 changed files with 3722 additions and 543 deletions

View File

@@ -37,6 +37,7 @@
"xss": "^1.0.15"
},
"peerDependencies": {
"@ant-design/icons": "^5.0.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^7.29.4",

View File

@@ -69,6 +69,8 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
rowCount: number;
wrapperRef?: MutableRefObject<HTMLDivElement>;
onColumnOrderChange: () => void;
renderGroupingHeaders?: () => JSX.Element;
renderTimeComparisonDropdown?: () => JSX.Element;
}
export interface RenderHTMLCellProps extends HTMLProps<HTMLTableCellElement> {
@@ -101,6 +103,8 @@ export default typedMemo(function DataTable<D extends object>({
serverPagination,
wrapperRef: userWrapperRef,
onColumnOrderChange,
renderGroupingHeaders,
renderTimeComparisonDropdown,
...moreUseTableOptions
}: DataTableProps<D>): JSX.Element {
const tableHooks: PluginHook<D>[] = [
@@ -117,7 +121,8 @@ export default typedMemo(function DataTable<D extends object>({
const sortByRef = useRef([]); // cache initial `sortby` so sorting doesn't trigger page reset
const pageSizeRef = useRef([initialPageSize, resultsSize]);
const hasPagination = initialPageSize > 0 && resultsSize > 0; // pageSize == 0 means no pagination
const hasGlobalControl = hasPagination || !!searchInput;
const hasGlobalControl =
hasPagination || !!searchInput || renderTimeComparisonDropdown;
const initialState = {
...initialState_,
// zero length means all pages, the `usePagination` plugin does not
@@ -253,6 +258,7 @@ export default typedMemo(function DataTable<D extends object>({
const renderTable = () => (
<table {...getTableProps({ className: tableClassName })}>
<thead>
{renderGroupingHeaders ? renderGroupingHeaders() : null}
{headerGroups.map(headerGroup => {
const { key: headerGroupKey, ...headerGroupProps } =
headerGroup.getHeaderGroupProps();
@@ -357,7 +363,9 @@ export default typedMemo(function DataTable<D extends object>({
{hasGlobalControl ? (
<div ref={globalControlRef} className="form-inline dt-controls">
<div className="row">
<div className="col-sm-6">
<div
className={renderTimeComparisonDropdown ? 'col-sm-5' : 'col-sm-6'}
>
{hasPagination ? (
<SelectPageSize
total={resultsSize}
@@ -384,6 +392,14 @@ export default typedMemo(function DataTable<D extends object>({
/>
</div>
) : null}
{renderTimeComparisonDropdown ? (
<div
className="col-sm-1"
style={{ float: 'right', marginTop: '6px' }}
>
{renderTimeComparisonDropdown()}
</div>
) : null}
</div>
</div>
) : null}

View File

@@ -183,7 +183,9 @@ function StickyWrap({
}
const fullTableHeight = (bodyThead.parentNode as HTMLTableElement)
.clientHeight;
const ths = bodyThead.childNodes[0]
// instead of always using the first tr, we use the last one to support
// multi-level headers assuming the last one is the more detailed one
const ths = bodyThead.childNodes?.[bodyThead.childNodes?.length - 1 || 0]
.childNodes as NodeListOf<HTMLTableHeaderCellElement>;
const widths = Array.from(ths).map(
th => th.getBoundingClientRect()?.width || th.clientWidth,

View File

@@ -111,5 +111,12 @@ export default styled.div`
text-align: center;
padding: 1em 0.6em;
}
.right-border-only {
border-right: 2px solid ${theme.colors.grayscale.light2};
}
table .right-border-only:last-child {
border-right: none;
}
`}
`;

View File

@@ -50,9 +50,23 @@ import {
css,
t,
tn,
useTheme,
} from '@superset-ui/core';
import { Dropdown, Menu } from '@superset-ui/chart-controls';
import {
CheckOutlined,
DownOutlined,
MinusCircleOutlined,
PlusCircleOutlined,
TableOutlined,
} from '@ant-design/icons';
import { DataColumnMeta, TableChartTransformedProps } from './types';
import { isEmpty } from 'lodash';
import {
ColorSchemeEnum,
DataColumnMeta,
TableChartTransformedProps,
} from './types';
import DataTable, {
DataTableProps,
SearchInputProps,
@@ -242,7 +256,16 @@ export default function TableChart<D extends DataRecord = DataRecord>(
allowRenderHtml = true,
onContextMenu,
emitCrossFilters,
isUsingTimeComparison,
basicColorFormatters,
basicColorColumnFormatters,
} = props;
const comparisonColumns = [
{ key: 'all', label: t('Display all') },
{ key: '#', label: '#' },
{ key: '△', label: '△' },
{ key: '%', label: '%' },
];
const timestampFormatter = useCallback(
value => getTimeFormatterForGranularity(timeGrain)(value),
[timeGrain],
@@ -253,6 +276,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
});
// keep track of whether column order changed, so that column widths can too
const [columnOrderToggle, setColumnOrderToggle] = useState(false);
const [showComparisonDropdown, setShowComparisonDropdown] = useState(false);
const [selectedComparisonColumns, setSelectedComparisonColumns] = useState([
comparisonColumns[0].key,
]);
const [hideComparisonKeys, setHideComparisonKeys] = useState<string[]>([]);
const theme = useTheme();
// only take relevant page size options
const pageSizeOptions = useMemo(() => {
@@ -362,16 +391,46 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const getSharedStyle = (column: DataColumnMeta): CSSProperties => {
const { isNumeric, config = {} } = column;
const textAlign = config.horizontalAlign
? config.horizontalAlign
: isNumeric
? 'right'
: 'left';
const textAlign =
config.horizontalAlign ||
(isNumeric && !isUsingTimeComparison ? 'right' : 'left');
return {
textAlign,
};
};
const comparisonLabels = [t('Main'), '#', '△', '%'];
const filteredColumnsMeta = useMemo(() => {
if (!isUsingTimeComparison) {
return columnsMeta;
}
const allColumns = comparisonColumns[0].key;
const main = comparisonLabels[0];
const showAllColumns = selectedComparisonColumns.includes(allColumns);
return columnsMeta.filter(({ label, key }) => {
// Extract the key portion after the space, assuming the format is always "label key"
const keyPortion = key.substring(label.length);
const isKeyHidded = hideComparisonKeys.includes(keyPortion);
const isLableMain = label === main;
return (
isLableMain ||
(!isKeyHidded &&
(!comparisonLabels.includes(label) ||
showAllColumns ||
selectedComparisonColumns.includes(label)))
);
});
}, [
columnsMeta,
comparisonColumns,
comparisonLabels,
isUsingTimeComparison,
hideComparisonKeys,
selectedComparisonColumns,
]);
const handleContextMenu =
onContextMenu && !isRawRecords
? (
@@ -385,7 +444,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
clientY: number,
) => {
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
columnsMeta.forEach(col => {
filteredColumnsMeta.forEach(col => {
if (!col.isMetric) {
const dataRecordValue = value[col.key];
drillToDetailFilters.push({
@@ -417,6 +476,198 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
: undefined;
const getHeaderColumns = (
columnsMeta: DataColumnMeta[],
enableTimeComparison?: boolean,
) => {
const resultMap: Record<string, number[]> = {};
if (!enableTimeComparison) {
return resultMap;
}
columnsMeta.forEach((element, index) => {
// Check if element's label is one of the comparison labels
if (comparisonLabels.includes(element.label)) {
// Extract the key portion after the space, assuming the format is always "label key"
const keyPortion = element.key.substring(element.label.length);
// If the key portion is not in the map, initialize it with the current index
if (!resultMap[keyPortion]) {
resultMap[keyPortion] = [index];
} else {
// Add the index to the existing array
resultMap[keyPortion].push(index);
}
}
});
return resultMap;
};
const renderTimeComparisonDropdown = (): JSX.Element => {
const allKey = comparisonColumns[0].key;
const handleOnClick = (data: any) => {
const { key } = data;
// Toggle 'All' key selection
if (key === allKey) {
setSelectedComparisonColumns([allKey]);
} else if (selectedComparisonColumns.includes(allKey)) {
setSelectedComparisonColumns([key]);
} else {
// Toggle selection for other keys
setSelectedComparisonColumns(
selectedComparisonColumns.includes(key)
? selectedComparisonColumns.filter(k => k !== key) // Deselect if already selected
: [...selectedComparisonColumns, key],
); // Select if not already selected
}
};
const handleOnBlur = () => {
if (selectedComparisonColumns.length === 3) {
setSelectedComparisonColumns([comparisonColumns[0].key]);
}
};
return (
<Dropdown
placement="bottomRight"
visible={showComparisonDropdown}
onVisibleChange={(flag: boolean) => {
setShowComparisonDropdown(flag);
}}
overlay={
<Menu
multiple
onClick={handleOnClick}
onBlur={handleOnBlur}
selectedKeys={selectedComparisonColumns}
>
<div
css={css`
max-width: 242px;
padding: 0 ${theme.gridUnit * 2}px;
color: ${theme.colors.grayscale.base};
font-size: ${theme.typography.sizes.s}px;
`}
>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
)}
</div>
{comparisonColumns.map(column => (
<Menu.Item key={column.key}>
<span
css={css`
color: ${theme.colors.grayscale.dark2};
`}
>
{column.label}
</span>
<span
css={css`
float: right;
font-size: ${theme.typography.sizes.s}px;
`}
>
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</span>
</Menu.Item>
))}
</Menu>
}
trigger={['click']}
>
<span>
<TableOutlined /> <DownOutlined />
</span>
</Dropdown>
);
};
const renderGroupingHeaders = (): JSX.Element => {
// TODO: Make use of ColumnGroup to render the aditional headers
const headers: any = [];
let currentColumnIndex = 0;
Object.entries(groupHeaderColumns || {}).forEach(([key, value]) => {
// Calculate the number of placeholder columns needed before the current header
const startPosition = value[0];
const colSpan = value.length;
// Add placeholder <th> for columns before this header
for (let i = currentColumnIndex; i < startPosition; i += 1) {
headers.push(
<th
key={`placeholder-${i}`}
style={{ borderBottom: 0 }}
aria-label={`Header-${i}`}
/>,
);
}
// Add the current header <th>
headers.push(
<th key={`header-${key}`} colSpan={colSpan} style={{ borderBottom: 0 }}>
{key}
<span
css={css`
float: right;
& svg {
color: ${theme.colors.grayscale.base} !important;
}
`}
>
{hideComparisonKeys.includes(key) ? (
<PlusCircleOutlined
onClick={() =>
setHideComparisonKeys(
hideComparisonKeys.filter(k => k !== key),
)
}
/>
) : (
<MinusCircleOutlined
onClick={() =>
setHideComparisonKeys([...hideComparisonKeys, key])
}
/>
)}
</span>
</th>,
);
// Update the current column index
currentColumnIndex = startPosition + colSpan;
});
return (
<tr
css={css`
th {
border-right: 2px solid ${theme.colors.grayscale.light2};
}
th:first-child {
border-left: none;
}
th:last-child {
border-right: none;
}
`}
>
{headers}
</tr>
);
};
const groupHeaderColumns = useMemo(
() => getHeaderColumns(filteredColumnsMeta, isUsingTimeComparison),
[filteredColumnsMeta, isUsingTimeComparison],
);
const getColumnConfigs = useCallback(
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
const {
@@ -451,7 +702,13 @@ export default function TableChart<D extends DataRecord = DataRecord>(
Array.isArray(columnColorFormatters) &&
columnColorFormatters.length > 0;
const hasBasicColorFormatters =
isUsingTimeComparison &&
Array.isArray(basicColorFormatters) &&
basicColorFormatters.length > 0;
const valueRange =
!hasBasicColorFormatters &&
!hasColumnColorFormatters &&
(config.showCellBars === undefined
? showCellBars
@@ -464,6 +721,16 @@ export default function TableChart<D extends DataRecord = DataRecord>(
className += ' dt-is-filter';
}
if (!isMetric && !isPercentMetric) {
className += ' right-border-only';
} else if (comparisonLabels.includes(label)) {
const groupinHeader = key.substring(label.length);
const columnsUnderHeader = groupHeaderColumns[groupinHeader] || [];
if (i === columnsUnderHeader[columnsUnderHeader.length - 1]) {
className += ' right-border-only';
}
}
return {
id: String(i), // to allow duplicate column keys
// must use custom accessor to allow `.` in column names
@@ -475,6 +742,17 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const html = isHtml && allowRenderHtml ? { __html: text } : undefined;
let backgroundColor;
let arrow = '';
const originKey = column.key.substring(column.label.length).trim();
if (!hasColumnColorFormatters && hasBasicColorFormatters) {
backgroundColor =
basicColorFormatters[row.index][originKey]?.backgroundColor;
arrow =
column.label === comparisonLabels[0]
? basicColorFormatters[row.index][originKey]?.mainArrow
: '';
}
if (hasColumnColorFormatters) {
columnColorFormatters!
.filter(formatter => formatter.column === column.key)
@@ -489,6 +767,19 @@ export default function TableChart<D extends DataRecord = DataRecord>(
});
}
if (
basicColorColumnFormatters &&
basicColorColumnFormatters?.length > 0
) {
backgroundColor =
basicColorColumnFormatters[row.index][column.key]
?.backgroundColor || backgroundColor;
arrow =
column.label === comparisonLabels[0]
? basicColorColumnFormatters[row.index][column.key]?.mainArrow
: '';
}
const StyledCell = styled.td`
text-align: ${sharedStyle.textAlign};
white-space: ${value instanceof Date ? 'nowrap' : undefined};
@@ -520,6 +811,28 @@ export default function TableChart<D extends DataRecord = DataRecord>(
`}
`;
let arrowStyles = css`
color: ${basicColorFormatters &&
basicColorFormatters[row.index][originKey]?.arrowColor ===
ColorSchemeEnum.Green
? theme.colors.success.base
: theme.colors.error.base};
margin-right: ${theme.gridUnit}px;
`;
if (
basicColorColumnFormatters &&
basicColorColumnFormatters?.length > 0
) {
arrowStyles = css`
color: ${basicColorColumnFormatters[row.index][column.key]
?.arrowColor === ColorSchemeEnum.Green
? theme.colors.success.base
: theme.colors.error.base};
margin-right: ${theme.gridUnit}px;
`;
}
const cellProps = {
'aria-labelledby': `header-${column.key}`,
role: 'cell',
@@ -589,10 +902,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
className="dt-truncate-cell"
style={columnWidth ? { width: columnWidth } : undefined}
>
{arrow && <span css={arrowStyles}>{arrow}</span>}
{text}
</div>
) : (
text
<>
{arrow && <span css={arrowStyles}>{arrow}</span>}
{text}
</>
)}
</StyledCell>
);
@@ -676,8 +993,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
);
const columns = useMemo(
() => columnsMeta.map(getColumnConfigs),
[columnsMeta, getColumnConfigs],
() => filteredColumnsMeta.map(getColumnConfigs),
[filteredColumnsMeta, getColumnConfigs],
);
const handleServerPaginationChange = useCallback(
@@ -744,6 +1061,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
selectPageSize={pageSize !== null && SelectPageSize}
// not in use in Superset, but needed for unit tests
sticky={sticky}
renderGroupingHeaders={
!isEmpty(groupHeaderColumns) ? renderGroupingHeaders : undefined
}
renderTimeComparisonDropdown={
isUsingTimeComparison ? renderTimeComparisonDropdown : undefined
}
/>
</Styles>
);

View File

@@ -21,13 +21,21 @@ import {
buildQueryContext,
ensureIsArray,
getMetricLabel,
getTimeOffset,
isPhysicalColumn,
parseDttmToDate,
QueryMode,
QueryObject,
removeDuplicates,
SimpleAdhocFilter,
} from '@superset-ui/core';
import { PostProcessingRule } from '@superset-ui/core/src/query/types/PostProcessing';
import { BuildQuery } from '@superset-ui/core/src/chart/registries/ChartBuildQueryRegistrySingleton';
import {
isTimeComparison,
timeCompareOperator,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash';
import { TableChartFormData } from './types';
import { updateExternalFormData } from './DataTable/utils/externalAPIs';
@@ -69,9 +77,51 @@ const buildQuery: BuildQuery<TableChartFormData> = (
};
}
const addComparisonPercentMetrics = (metrics: string[], suffixes: string[]) =>
metrics.reduce<string[]>((acc, metric) => {
const newMetrics = suffixes.map(suffix => `${metric}__${suffix}`);
return acc.concat([metric, ...newMetrics]);
}, []);
return buildQueryContext(formDataCopy, baseQueryObject => {
let { metrics, orderby = [], columns = [] } = baseQueryObject;
const { extras = {} } = baseQueryObject;
let postProcessing: PostProcessingRule[] = [];
const TimeRangeFilters =
formData.adhoc_filters?.filter(
(filter: SimpleAdhocFilter) => filter.operator === 'TEMPORAL_RANGE',
) || [];
// In case the viz is using all version of controls, we try to load them
const previousCustomTimeRangeFilters: any =
formData.adhoc_custom?.filter(
(filter: SimpleAdhocFilter) => filter.operator === 'TEMPORAL_RANGE',
) || [];
let previousCustomStartDate = '';
if (
!isEmpty(previousCustomTimeRangeFilters) &&
previousCustomTimeRangeFilters[0]?.comparator !== 'No Filter'
) {
previousCustomStartDate =
previousCustomTimeRangeFilters[0]?.comparator.split(' : ')[0];
}
const timeOffsets = ensureIsArray(
isTimeComparison(formData, baseQueryObject)
? getTimeOffset({
timeRangeFilter: TimeRangeFilters[0],
shifts: formData.time_compare,
startDate:
previousCustomStartDate && !formData.start_date_offset
? parseDttmToDate(previousCustomStartDate)?.toUTCString()
: formData.start_date_offset,
})
: [],
);
let temporalColumAdded = false;
let temporalColum = null;
if (queryMode === QueryMode.Aggregate) {
metrics = metrics || [];
@@ -85,8 +135,17 @@ const buildQuery: BuildQuery<TableChartFormData> = (
}
// add postprocessing for percent metrics only when in aggregation mode
if (percentMetrics && percentMetrics.length > 0) {
const percentMetricsLabelsWithTimeComparison = isTimeComparison(
formData,
baseQueryObject,
)
? addComparisonPercentMetrics(
percentMetrics.map(getMetricLabel),
timeOffsets,
)
: percentMetrics.map(getMetricLabel);
const percentMetricLabels = removeDuplicates(
percentMetrics.map(getMetricLabel),
percentMetricsLabelsWithTimeComparison,
);
metrics = removeDuplicates(
metrics.concat(percentMetrics),
@@ -102,23 +161,38 @@ const buildQuery: BuildQuery<TableChartFormData> = (
},
];
}
// Add the operator for the time comparison if some is selected
if (!isEmpty(timeOffsets)) {
postProcessing.push(timeCompareOperator(formData, baseQueryObject));
}
columns = columns.map(col => {
if (
const temporalColumnsLookup = formData?.temporal_columns_lookup;
// Filter out the column if needed and prepare the temporal column object
columns = columns.filter(col => {
const shouldBeAdded =
isPhysicalColumn(col) &&
time_grain_sqla &&
formData?.temporal_columns_lookup?.[col]
) {
return {
temporalColumnsLookup?.[col];
if (shouldBeAdded && !temporalColumAdded) {
temporalColum = {
timeGrain: time_grain_sqla,
columnType: 'BASE_AXIS',
sqlExpression: col,
label: col,
expressionType: 'SQL',
} as AdhocColumn;
temporalColumAdded = true;
return false; // Do not include this in the output; it's added separately
}
return col;
return true;
});
// So we ensure the temporal column is added first
if (temporalColum) {
columns = [temporalColum, ...columns];
}
}
const moreProps: Partial<QueryObject> = {};
@@ -133,9 +207,11 @@ const buildQuery: BuildQuery<TableChartFormData> = (
let queryObject = {
...baseQueryObject,
columns,
extras: !isEmpty(timeOffsets) && !temporalColum ? {} : extras,
orderby,
metrics,
post_processing: postProcessing,
time_offsets: timeOffsets,
...moreProps,
};
@@ -169,6 +245,7 @@ const buildQuery: BuildQuery<TableChartFormData> = (
row_limit: 0,
row_offset: 0,
post_processing: [],
extras: undefined, // we don't need time grain here
order_desc: undefined, // we don't need orderby stuff here,
orderby: undefined, // because this query will be used for get total aggregation.
});
@@ -186,6 +263,7 @@ const buildQuery: BuildQuery<TableChartFormData> = (
{ ...queryObject },
{
...queryObject,
time_offsets: [],
row_limit: 0,
row_offset: 0,
post_processing: [],

View File

@@ -43,9 +43,12 @@ import {
ColumnMeta,
defineSavedMetrics,
getStandardizedControls,
sections,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash';
import { PAGE_SIZE_OPTIONS } from './consts';
import { ColorSchemeEnum } from './types';
function getQueryMode(controls: ControlStateMapping): QueryMode {
const mode = controls?.query_mode?.value;
@@ -142,6 +145,33 @@ const percentMetricsControl: typeof sharedControls.metrics = {
validators: [],
};
const processComparisonColumns = (columns: any[], suffix: string) =>
columns
.map(col => {
if (!col.label.includes(suffix)) {
return [
{
label: `${t('Main')} ${col.label}`,
value: `${t('Main')} ${col.value}`,
},
{
label: `# ${col.label}`,
value: `# ${col.value}`,
},
{
label: `${col.label}`,
value: `${col.value}`,
},
{
label: `% ${col.label}`,
value: `% ${col.value}`,
},
];
}
return [];
})
.flat();
const config: ControlPanelConfig = {
controlPanelSections: [
{
@@ -397,44 +427,6 @@ const config: ControlPanelConfig = {
description: t('Whether to include a client-side search box'),
},
},
{
name: 'show_cell_bars',
config: {
type: 'CheckboxControl',
label: t('Cell bars'),
renderTrigger: true,
default: true,
description: t(
'Whether to display a bar chart background in table columns',
),
},
},
],
[
{
name: 'align_pn',
config: {
type: 'CheckboxControl',
label: t('Align +/-'),
renderTrigger: true,
default: false,
description: t(
'Whether to align background charts with both positive and negative values at 0',
),
},
},
{
name: 'color_pn',
config: {
type: 'CheckboxControl',
label: t('Color +/-'),
renderTrigger: true,
default: true,
description: t(
'Whether to colorize numeric values by whether they are positive or negative',
),
},
},
],
[
{
@@ -447,6 +439,8 @@ const config: ControlPanelConfig = {
description: t(
"Allow end user to drag-and-drop column headers to rearrange them. Note their changes won't persist for the next time they open the chart.",
),
visibility: ({ controls }) =>
isEmpty(controls?.time_compare?.value),
},
},
],
@@ -485,13 +479,111 @@ const config: ControlPanelConfig = {
},
},
],
],
},
{
label: t('Visual formatting'),
expanded: true,
controlSetRows: [
[
{
name: 'show_cell_bars',
config: {
type: 'CheckboxControl',
label: t('Show Cell bars'),
renderTrigger: true,
default: true,
description: t(
'Whether to display a bar chart background in table columns',
),
},
},
],
[
{
name: 'align_pn',
config: {
type: 'CheckboxControl',
label: t('Align +/-'),
renderTrigger: true,
default: false,
description: t(
'Whether to align background charts with both positive and negative values at 0',
),
},
},
],
[
{
name: 'color_pn',
config: {
type: 'CheckboxControl',
label: t('add colors to cell bars for +/-'),
renderTrigger: true,
default: true,
description: t(
'Whether to colorize numeric values by whether they are positive or negative',
),
},
},
],
[
{
name: 'comparison_color_enabled',
config: {
type: 'CheckboxControl',
label: t('basic conditional formatting'),
renderTrigger: true,
visibility: ({ controls }) =>
!isEmpty(controls?.time_compare?.value),
default: false,
description: t(
'This will be applied to the whole table. Arrows (↑ and ↓) will be added to ' +
'main columns for increase and decrease. Basic conditional formatting can be ' +
'overwritten by conditional formatting below.',
),
},
},
],
[
{
name: 'comparison_color_scheme',
config: {
type: 'SelectControl',
label: t('color type'),
default: ColorSchemeEnum.Green,
renderTrigger: true,
choices: [
[ColorSchemeEnum.Green, 'Green for increase, red for decrease'],
[ColorSchemeEnum.Red, 'Red for increase, green for decrease'],
],
visibility: ({ controls }) =>
!isEmpty(controls?.time_compare?.value) &&
Boolean(controls?.comparison_color_enabled?.value),
description: t(
'Adds color to the chart symbols based on the positive or ' +
'negative change from the comparison value.',
),
},
},
],
[
{
name: 'conditional_formatting',
config: {
type: 'ConditionalFormattingControl',
renderTrigger: true,
label: t('Conditional formatting'),
label: t('Custom Conditional Formatting'),
extraColorChoices: [
{
value: ColorSchemeEnum.Green,
label: t('Green for increase, red for decrease'),
},
{
value: ColorSchemeEnum.Red,
label: t('Red for increase, green for decrease'),
},
],
description: t(
'Apply conditional color formatting to numeric columns',
),
@@ -519,9 +611,18 @@ const config: ControlPanelConfig = {
label: verboseMap[colname] ?? colname,
}))
: [];
const columnOptions = explore?.controls?.time_compare?.value
? processComparisonColumns(
numericColumns || [],
ensureIsArray(
explore?.controls?.time_compare?.value,
)[0]?.toString() || '',
)
: numericColumns;
return {
removeIrrelevantConditions: chartStatus === 'success',
columnOptions: numericColumns,
columnOptions,
verboseMap,
};
},
@@ -530,6 +631,14 @@ const config: ControlPanelConfig = {
],
],
},
{
...sections.timeComparisonControls({
multi: false,
showCalculationType: false,
showFullChoices: false,
}),
visibility: isAggMode,
},
],
formDataOverrides: formData => ({
...formData,

View File

@@ -18,8 +18,10 @@
*/
import memoizeOne from 'memoize-one';
import {
ComparisonType,
CurrencyFormatter,
DataRecord,
ensureIsArray,
extractTimegrain,
GenericDataType,
getMetricLabel,
@@ -28,18 +30,26 @@ import {
getTimeFormatterForGranularity,
NumberFormats,
QueryMode,
t,
SMART_DATE_ID,
TimeFormats,
TimeFormatter,
SimpleAdhocFilter,
getTimeOffset,
parseDttmToDate,
} from '@superset-ui/core';
import {
ColorFormatters,
ConditionalFormattingConfig,
getColorFormatters,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import {
BasicColorFormatterType,
ColorSchemeEnum,
DataColumnMeta,
TableChartProps,
TableChartTransformedProps,
@@ -81,6 +91,103 @@ const processDataRecords = memoizeOne(function processDataRecords(
return data;
});
const calculateDifferences = (
originalValue: number,
comparisonValue: number,
) => {
const valueDifference = originalValue - comparisonValue;
let percentDifferenceNum;
if (!originalValue && !comparisonValue) {
percentDifferenceNum = 0;
} else if (!originalValue || !comparisonValue) {
percentDifferenceNum = originalValue ? 1 : -1;
} else {
percentDifferenceNum =
(originalValue - comparisonValue) / Math.abs(comparisonValue);
}
return { valueDifference, percentDifferenceNum };
};
const processComparisonTotals = (
comparisonSuffix: string,
totals?: DataRecord[],
): DataRecord | undefined => {
if (!totals) {
return totals;
}
const transformedTotals: DataRecord = {};
totals.map((totalRecord: DataRecord) =>
Object.keys(totalRecord).forEach(key => {
if (totalRecord[key] !== undefined && !key.includes(comparisonSuffix)) {
transformedTotals[`Main ${key}`] =
parseInt(transformedTotals[`Main ${key}`]?.toString() || '0', 10) +
parseInt(totalRecord[key]?.toString() || '0', 10);
transformedTotals[`# ${key}`] =
parseInt(transformedTotals[`# ${key}`]?.toString() || '0', 10) +
parseInt(
totalRecord[`${key}__${comparisonSuffix}`]?.toString() || '0',
10,
);
const { valueDifference, percentDifferenceNum } = calculateDifferences(
transformedTotals[`Main ${key}`] as number,
transformedTotals[`# ${key}`] as number,
);
transformedTotals[`${key}`] = valueDifference;
transformedTotals[`% ${key}`] = percentDifferenceNum;
}
}),
);
return transformedTotals;
};
const processComparisonDataRecords = memoizeOne(
function processComparisonDataRecords(
originalData: DataRecord[] | undefined,
originalColumns: DataColumnMeta[],
comparisonSuffix: string,
) {
// Transform data
return originalData?.map(originalItem => {
const transformedItem: DataRecord = {};
originalColumns.forEach(origCol => {
if (
(origCol.isMetric || origCol.isPercentMetric) &&
!origCol.key.includes(comparisonSuffix) &&
origCol.isNumeric
) {
const originalValue = originalItem[origCol.key] || 0;
const comparisonValue = origCol.isMetric
? originalItem?.[`${origCol.key}__${comparisonSuffix}`] || 0
: originalItem[`%${origCol.key.slice(1)}__${comparisonSuffix}`] ||
0;
const { valueDifference, percentDifferenceNum } =
calculateDifferences(
originalValue as number,
comparisonValue as number,
);
transformedItem[`Main ${origCol.key}`] = originalValue;
transformedItem[`# ${origCol.key}`] = comparisonValue;
transformedItem[`${origCol.key}`] = valueDifference;
transformedItem[`% ${origCol.key}`] = percentDifferenceNum;
}
});
Object.keys(originalItem).forEach(key => {
const isMetricOrPercentMetric = originalColumns.some(
col => col.key === key && (col.isMetric || col.isPercentMetric),
);
if (!isMetricOrPercentMetric) {
transformedItem[key] = originalItem[key];
}
});
return transformedItem;
});
},
);
const processColumns = memoizeOne(function processColumns(
props: TableChartProps,
) {
@@ -186,6 +293,60 @@ const processColumns = memoizeOne(function processColumns(
];
}, isEqualColumns);
const processComparisonColumns = (
columns: DataColumnMeta[],
props: TableChartProps,
comparisonSuffix: string,
) =>
columns
.map(col => {
const {
datasource: { columnFormats },
rawFormData: { column_config: columnConfig = {} },
} = props;
const config = columnConfig[col.key] || {};
const savedFormat = columnFormats?.[col.key];
const numberFormat = config.d3NumberFormat || savedFormat;
if (
(col.isMetric || col.isPercentMetric) &&
!col.key.includes(comparisonSuffix) &&
col.isNumeric
) {
return [
{
...col,
label: t('Main'),
key: `${t('Main')} ${col.key}`,
},
{
...col,
label: `#`,
key: `# ${col.key}`,
},
{
...col,
label: ``,
key: `${col.key}`,
},
{
...col,
formatter: getNumberFormatter(numberFormat || PERCENT_3_POINT),
label: `%`,
key: `% ${col.key}`,
},
];
}
if (
!col.isMetric &&
!col.isPercentMetric &&
!col.key.includes(comparisonSuffix)
) {
return [col];
}
return [];
})
.flat();
/**
* Automatically set page size based on number of cells.
*/
@@ -239,10 +400,168 @@ const transformProps = (
conditional_formatting: conditionalFormatting,
allow_rearrange_columns: allowRearrangeColumns,
allow_render_html: allowRenderHtml,
time_compare,
comparison_color_enabled: comparisonColorEnabled = false,
comparison_color_scheme: comparisonColorScheme = ColorSchemeEnum.Green,
comparison_type,
} = formData;
const isUsingTimeComparison =
!isEmpty(time_compare) &&
queryMode === QueryMode.Aggregate &&
comparison_type === ComparisonType.Values;
const calculateBasicStyle = (
percentDifferenceNum: number,
colorOption: ColorSchemeEnum,
) => {
if (percentDifferenceNum === 0) {
return {
arrow: '',
arrowColor: '',
// eslint-disable-next-line theme-colors/no-literal-colors
backgroundColor: 'rgba(0,0,0,0.2)',
};
}
const isPositive = percentDifferenceNum > 0;
const arrow = isPositive ? '↑' : '↓';
const arrowColor =
colorOption === ColorSchemeEnum.Green
? isPositive
? ColorSchemeEnum.Green
: ColorSchemeEnum.Red
: isPositive
? ColorSchemeEnum.Red
: ColorSchemeEnum.Green;
const backgroundColor =
colorOption === ColorSchemeEnum.Green
? `rgba(${isPositive ? '0,150,0' : '150,0,0'},0.2)`
: `rgba(${isPositive ? '150,0,0' : '0,150,0'},0.2)`;
return { arrow, arrowColor, backgroundColor };
};
const getBasicColorFormatter = memoizeOne(function getBasicColorFormatter(
originalData: DataRecord[] | undefined,
originalColumns: DataColumnMeta[],
selectedColumns?: ConditionalFormattingConfig[],
) {
// Transform data
const relevantColumns = selectedColumns
? originalColumns.filter(col =>
selectedColumns.some(scol => scol?.column?.includes(col.key)),
)
: originalColumns;
return originalData?.map(originalItem => {
const item: { [key: string]: BasicColorFormatterType } = {};
relevantColumns.forEach(origCol => {
if (
(origCol.isMetric || origCol.isPercentMetric) &&
!origCol.key.includes(ensureIsArray(timeOffsets)[0]) &&
origCol.isNumeric
) {
const originalValue = originalItem[origCol.key] || 0;
const comparisonValue = origCol.isMetric
? originalItem?.[
`${origCol.key}__${ensureIsArray(timeOffsets)[0]}`
] || 0
: originalItem[
`%${origCol.key.slice(1)}__${ensureIsArray(timeOffsets)[0]}`
] || 0;
const { percentDifferenceNum } = calculateDifferences(
originalValue as number,
comparisonValue as number,
);
if (selectedColumns) {
selectedColumns.forEach(col => {
if (col?.column?.includes(origCol.key)) {
const { arrow, arrowColor, backgroundColor } =
calculateBasicStyle(
percentDifferenceNum,
col.colorScheme || comparisonColorScheme,
);
item[col.column] = {
mainArrow: arrow,
arrowColor,
backgroundColor,
};
}
});
} else {
const { arrow, arrowColor, backgroundColor } = calculateBasicStyle(
percentDifferenceNum,
comparisonColorScheme,
);
item[`${origCol.key}`] = {
mainArrow: arrow,
arrowColor,
backgroundColor,
};
}
}
});
return item;
});
});
const getBasicColorFormatterForColumn = (
originalData: DataRecord[] | undefined,
originalColumns: DataColumnMeta[],
conditionalFormatting?: ConditionalFormattingConfig[],
) => {
const selectedColumns = conditionalFormatting?.filter(
(config: ConditionalFormattingConfig) =>
config.column &&
(config.colorScheme === ColorSchemeEnum.Green ||
config.colorScheme === ColorSchemeEnum.Red),
);
return selectedColumns?.length
? getBasicColorFormatter(originalData, originalColumns, selectedColumns)
: undefined;
};
const timeGrain = extractTimegrain(formData);
const TimeRangeFilters =
chartProps.rawFormData?.adhoc_filters?.filter(
(filter: SimpleAdhocFilter) => filter.operator === 'TEMPORAL_RANGE',
) || [];
const previousCustomTimeRangeFilters: any =
chartProps.rawFormData?.adhoc_custom?.filter(
(filter: SimpleAdhocFilter) => filter.operator === 'TEMPORAL_RANGE',
) || [];
let previousCustomStartDate = '';
if (
!isEmpty(previousCustomTimeRangeFilters) &&
previousCustomTimeRangeFilters[0]?.comparator !== 'No Filter'
) {
previousCustomStartDate =
previousCustomTimeRangeFilters[0]?.comparator.split(' : ')[0];
}
const timeOffsets = getTimeOffset({
timeRangeFilter: TimeRangeFilters[0],
shifts: formData.time_compare,
startDate:
previousCustomStartDate && !formData.start_date_offset
? parseDttmToDate(previousCustomStartDate)?.toUTCString()
: formData.start_date_offset,
});
const comparisonSuffix = isUsingTimeComparison
? ensureIsArray(timeOffsets)[0]
: '';
const [metrics, percentMetrics, columns] = processColumns(chartProps);
let comparisonColumns: DataColumnMeta[] = [];
if (isUsingTimeComparison) {
comparisonColumns = processComparisonColumns(
columns,
chartProps,
comparisonSuffix,
);
}
let baseQuery;
let countQuery;
@@ -256,20 +575,41 @@ const transformProps = (
rowCount = baseQuery?.rowcount ?? 0;
}
const data = processDataRecords(baseQuery?.data, columns);
const comparisonData = processComparisonDataRecords(
baseQuery?.data,
columns,
comparisonSuffix,
);
const totals =
showTotals && queryMode === QueryMode.Aggregate
? totalQuery?.data[0]
? isUsingTimeComparison
? processComparisonTotals(comparisonSuffix, totalQuery?.data)
: totalQuery?.data[0]
: undefined;
const columnColorFormatters =
getColorFormatters(conditionalFormatting, data) ?? defaultColorFormatters;
const passedData = isUsingTimeComparison ? comparisonData || [] : data;
const passedColumns = isUsingTimeComparison ? comparisonColumns : columns;
const basicColorFormatters =
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
const columnColorFormatters =
getColorFormatters(conditionalFormatting, passedData) ??
defaultColorFormatters;
const basicColorColumnFormatters = getBasicColorFormatterForColumn(
baseQuery?.data,
columns,
conditionalFormatting,
);
const startDateOffset = chartProps.rawFormData?.start_date_offset;
return {
height,
width,
isRawRecords: queryMode === QueryMode.Raw,
data,
data: passedData,
totals,
columns,
columns: passedColumns,
serverPagination,
metrics,
percentMetrics,
@@ -294,6 +634,10 @@ const transformProps = (
allowRearrangeColumns,
allowRenderHtml,
onContextMenu,
isUsingTimeComparison,
basicColorFormatters,
startDateOffset,
basicColorColumnFormatters,
};
};

View File

@@ -102,6 +102,12 @@ export interface TableChartProps extends ChartProps {
queriesData: ChartDataResponseResult[];
}
export type BasicColorFormatterType = {
backgroundColor: string;
arrowColor: string;
mainArrow: string;
};
export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
timeGrain?: TimeGranularity;
height: number;
@@ -136,6 +142,15 @@ export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
clientY: number,
filters?: ContextMenuFilters,
) => void;
isUsingTimeComparison?: boolean;
basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[];
basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[];
startDateOffset?: string;
}
export enum ColorSchemeEnum {
'Green' = 'Green',
'Red' = 'Red',
}
export default {};