mirror of
https://github.com/apache/superset.git
synced 2026-04-19 16:14:52 +00:00
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:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user