feat(charts): add subtitle option and metric customization controls (#32975)

This commit is contained in:
Levis Mbote
2025-04-10 18:24:24 +03:00
committed by GitHub
parent 164a07e2be
commit d75ff9e784
19 changed files with 690 additions and 401 deletions

View File

@@ -81,6 +81,8 @@ export default function PopKPI(props: PopKPIProps) {
currentTimeRangeFilter,
startDateOffset,
shift,
subtitle,
subtitleFontSize,
dashboardTimeRange,
} = props;
@@ -140,6 +142,16 @@ export default function PopKPI(props: PopKPIProps) {
margin-bottom: ${theme.gridUnit * 4}px;
`;
const SubtitleText = styled.div`
${({ theme }) => `
font-family: ${theme.typography.families.sansSerif};
font-weight: ${theme.typography.weights.medium};
text-align: center;
margin-top: -10px;
margin-bottom: ${theme.gridUnit * 4}px;
`}
`;
const getArrowIndicatorColor = () => {
if (!comparisonColorEnabled || percentDifferenceNumber === 0) {
return theme.colors.grayscale.base;
@@ -195,31 +207,40 @@ export default function PopKPI(props: PopKPIProps) {
]);
const SYMBOLS_WITH_VALUES = useMemo(
() => [
{
symbol: '#',
value: prevNumber,
tooltipText: t('Data for %s', comparisonRange || 'previous range'),
columnKey: 'Previous value',
},
{
symbol: '△',
value: valueDifference,
tooltipText: t('Value difference between the time periods'),
columnKey: 'Delta',
},
{
symbol: '%',
value: percentDifferenceFormattedString,
tooltipText: t('Percentage difference between the time periods'),
columnKey: 'Percent change',
},
],
() =>
[
{
defaultSymbol: '#',
value: prevNumber,
tooltipText: t('Data for %s', comparisonRange || 'previous range'),
columnKey: 'Previous value',
},
{
defaultSymbol: '△',
value: valueDifference,
tooltipText: t('Value difference between the time periods'),
columnKey: 'Delta',
},
{
defaultSymbol: '%',
value: percentDifferenceFormattedString,
tooltipText: t('Percentage difference between the time periods'),
columnKey: 'Percent change',
},
].map(item => {
const config = props.columnConfig?.[item.columnKey];
return {
...item,
symbol: config?.displayTypeIcon === false ? '' : item.defaultSymbol,
label: config?.customColumnName || item.columnKey,
};
}),
[
comparisonRange,
prevNumber,
valueDifference,
percentDifferenceFormattedString,
props.columnConfig,
],
);
@@ -250,6 +271,15 @@ export default function PopKPI(props: PopKPIProps) {
</span>
)}
</div>
{subtitle && (
<SubtitleText
style={{
fontSize: `${subtitleFontSize * height * 0.4}px`,
}}
>
{subtitle}
</SubtitleText>
)}
{visibleSymbols.length > 0 && (
<div
@@ -276,7 +306,7 @@ export default function PopKPI(props: PopKPIProps) {
>
{visibleSymbols.map((symbol_with_value, index) => (
<ComparisonValue
key={`comparison-symbol-${symbol_with_value.symbol}`}
key={`comparison-symbol-${symbol_with_value.columnKey}`}
subheaderFontSize={subheaderFontSize}
>
<Tooltip
@@ -284,15 +314,19 @@ export default function PopKPI(props: PopKPIProps) {
placement="top"
title={symbol_with_value.tooltipText}
>
<SymbolWrapper
backgroundColor={
index > 0 ? backgroundColor : defaultBackgroundColor
}
textColor={index > 0 ? textColor : defaultTextColor}
>
{symbol_with_value.symbol}
</SymbolWrapper>
{symbol_with_value.value}
{symbol_with_value.symbol && (
<SymbolWrapper
backgroundColor={
index > 0 ? backgroundColor : defaultBackgroundColor
}
textColor={index > 0 ? textColor : defaultTextColor}
>
{symbol_with_value.symbol}
</SymbolWrapper>
)}
{symbol_with_value.value}{' '}
{props.columnConfig?.[symbol_with_value.columnKey]
?.customColumnName || ''}
</Tooltip>
</ComparisonValue>
))}

View File

@@ -23,7 +23,12 @@ import {
sharedControls,
sections,
} from '@superset-ui/chart-controls';
import { headerFontSize, subheaderFontSize } from '../sharedControls';
import {
headerFontSize,
subheaderFontSize,
subtitleControl,
subtitleFontSize,
} from '../sharedControls';
import { ColorSchemeEnum } from './types';
const config: ControlPanelConfig = {
@@ -63,6 +68,8 @@ const config: ControlPanelConfig = {
config: { ...headerFontSize.config, default: 0.2 },
},
],
[subtitleControl],
[subtitleFontSize],
[
{
...subheaderFontSize,
@@ -120,7 +127,11 @@ const config: ControlPanelConfig = {
[GenericDataType.Numeric]: [
{
tab: t('General'),
children: [['visible']],
children: [
['customColumnName'],
['displayTypeIcon'],
['visible'],
],
},
],
},

View File

@@ -89,6 +89,8 @@ export default function transformProps(chartProps: ChartProps) {
comparisonColorScheme,
comparisonColorEnabled,
percentDifferenceFormat,
subtitle = '',
subtitleFontSize,
columnConfig,
} = formData;
const { data: dataA = [] } = queriesData[0];
@@ -183,6 +185,8 @@ export default function transformProps(chartProps: ChartProps) {
valueDifference,
percentDifferenceFormattedString: percentDifference,
boldText,
subtitle,
subtitleFontSize,
headerFontSize: getHeaderFontSize(headerFontSize),
subheaderFontSize: getComparisonFontSize(subheaderFontSize),
headerText,

View File

@@ -35,6 +35,8 @@ export interface PopKPIStylesProps {
export type TableColumnConfig = {
visible?: boolean;
customColumnName?: string;
displayTypeIcon?: boolean;
};
interface PopKPICustomizeProps {
@@ -61,6 +63,8 @@ export type PopKPIProps = PopKPIStylesProps &
metricName: string;
bigNumber: string;
prevNumber: string;
subtitle?: string;
subtitleFontSize: number;
valueDifference: string;
percentDifferenceFormattedString: string;
compType: string;

View File

@@ -24,7 +24,11 @@ import {
Dataset,
getStandardizedControls,
} from '@superset-ui/chart-controls';
import { headerFontSize, subheaderFontSize } from '../sharedControls';
import {
headerFontSize,
subtitleFontSize,
subtitleControl,
} from '../sharedControls';
export default {
controlPanelSections: [
@@ -33,32 +37,13 @@ export default {
expanded: true,
controlSetRows: [['metric'], ['adhoc_filters']],
},
{
label: t('Display settings'),
expanded: true,
tabOverride: 'data',
controlSetRows: [
[
{
name: 'subheader',
config: {
type: 'TextControl',
label: t('Subheader'),
renderTrigger: true,
description: t(
'Description text that shows up below your Big Number',
),
},
},
],
],
},
{
label: t('Chart Options'),
expanded: true,
controlSetRows: [
[headerFontSize],
[subheaderFontSize],
[subtitleControl],
[subtitleFontSize],
['y_axis_format'],
['currency_format'],
[

View File

@@ -47,8 +47,8 @@ export default function transformProps(
const {
headerFontSize,
metric = 'value',
subheader = '',
subheaderFontSize,
subtitle = '',
subtitleFontSize,
forceTimestampFormatting,
timeFormat,
yAxisFormat,
@@ -59,7 +59,7 @@ export default function transformProps(
const { data = [], coltypes = [] } = queriesData[0];
const granularity = extractTimegrain(rawFormData as QueryFormData);
const metricName = getMetricLabel(metric);
const formattedSubheader = subheader;
const formattedSubtitle = subtitle;
const bigNumber =
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
@@ -105,8 +105,10 @@ export default function transformProps(
bigNumber,
headerFormatter,
headerFontSize,
subheaderFontSize,
subheader: formattedSubheader,
subtitleFontSize,
subtitle: formattedSubtitle,
subheader: '',
subheaderFontSize: subtitleFontSize,
onContextMenu,
refs,
colorThresholdFormatters,

View File

@@ -229,6 +229,40 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
return null;
}
renderSubtitle(maxHeight: number) {
const { subtitle, width } = this.props;
let fontSize = 0;
if (subtitle) {
const container = this.createTemporaryContainer();
document.body.append(container);
try {
fontSize = computeMaxFontSize({
text: subtitle,
maxWidth: width * 0.9,
maxHeight,
className: 'subtitle-line',
container,
});
} finally {
container.remove();
}
return (
<div
className="subtitle-line"
style={{
fontSize,
height: maxHeight,
}}
>
{subtitle}
</div>
);
}
return null;
}
renderTrendline(maxHeight: number) {
const { width, trendLineData, echartOptions, refs } = this.props;
@@ -282,6 +316,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
kickerFontSize,
headerFontSize,
subheaderFontSize,
subtitleFontSize,
} = this.props;
const className = this.getClassName();
@@ -306,6 +341,9 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
subheaderFontSize * (1 - PROPORTION.TRENDLINE) * height,
),
)}
{this.renderSubtitle(
Math.ceil(subtitleFontSize * (1 - PROPORTION.TRENDLINE) * height),
)}
</div>
{this.renderTrendline(chartHeight)}
</div>
@@ -318,6 +356,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
{this.renderKicker((kickerFontSize || 0) * height)}
{this.renderHeader(Math.ceil(headerFontSize * height))}
{this.renderSubheader(Math.ceil(subheaderFontSize * height))}
{this.renderSubtitle(Math.ceil(subtitleFontSize * height))}
</div>
);
}
@@ -368,7 +407,12 @@ export default styled(BigNumberVis)`
.subheader-line {
line-height: 1em;
padding-bottom: 0;
padding-bottom: 0.3em;
}
.subtitle-line {
line-height: 1em;
padding-top: 0.3em;
}
&.is-fallback-value {

View File

@@ -26,7 +26,12 @@ import {
getStandardizedControls,
temporalColumnMixin,
} from '@superset-ui/chart-controls';
import { headerFontSize, subheaderFontSize } from '../sharedControls';
import {
headerFontSize,
subheaderFontSize,
subtitleFontSize,
subtitleControl,
} from '../sharedControls';
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -134,6 +139,8 @@ const config: ControlPanelConfig = {
['color_picker', null],
[headerFontSize],
[subheaderFontSize],
[subtitleControl],
[subtitleFontSize],
['y_axis_format'],
['currency_format'],
[

View File

@@ -66,6 +66,8 @@ export default function transformProps(
metric = 'value',
showTimestamp,
showTrendLine,
subtitle = '',
subtitleFontSize,
aggregation,
startYAxisAtZero,
subheader = '',
@@ -302,6 +304,8 @@ export default function transformProps(
formatTime,
formData,
headerFontSize,
subtitleFontSize,
subtitle,
subheaderFontSize,
mainColor,
showTimestamp,

View File

@@ -55,6 +55,39 @@ export const headerFontSize: CustomControlItem = {
},
};
export const subtitleFontSize: CustomControlItem = {
name: 'subtitle_font_size',
config: {
type: 'SelectControl',
label: t('Subtitle Font Size'),
renderTrigger: true,
clearable: false,
default: 0.15,
// Values represent the percentage of space a subtitle should take
options: [
{
label: t('Tiny'),
value: 0.125,
},
{
label: t('Small'),
value: 0.15,
},
{
label: t('Normal'),
value: 0.2,
},
{
label: t('Large'),
value: 0.3,
},
{
label: t('Huge'),
value: 0.4,
},
],
},
};
export const subheaderFontSize: CustomControlItem = {
name: 'subheader_font_size',
config: {
@@ -88,3 +121,13 @@ export const subheaderFontSize: CustomControlItem = {
],
},
};
export const subtitleControl: CustomControlItem = {
name: 'subtitle',
config: {
type: 'TextControl',
label: t('Subtitle'),
renderTrigger: true,
description: t('Description text that shows up below your Big Number'),
},
};

View File

@@ -78,7 +78,9 @@ export type BigNumberVizProps = {
headerFontSize: number;
kickerFontSize?: number;
subheader: string;
subtitle: string;
subheaderFontSize: number;
subtitleFontSize: number;
showTimestamp?: boolean;
showTrendLine?: boolean;
startYAxisAtZero?: boolean;

View File

@@ -682,13 +682,33 @@ export default function TableChart<D extends DataRecord = DataRecord>(
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
const {
key,
label,
label: originalLabel,
isNumeric,
dataType,
isMetric,
isPercentMetric,
config = {},
} = column;
const label = config.customColumnName || originalLabel;
let displayLabel = label;
const isComparisonColumn = ['#', '△', '%', t('Main')].includes(
column.label,
);
if (isComparisonColumn) {
if (column.label === t('Main')) {
displayLabel = config.customColumnName || column.originalLabel || '';
} else if (config.customColumnName) {
displayLabel =
config.displayTypeIcon !== false
? `${column.label} ${config.customColumnName}`
: config.customColumnName;
} else if (config.displayTypeIcon === false) {
displayLabel = '';
}
}
const columnWidth = Number.isNaN(Number(config.columnWidth))
? config.columnWidth
: Number(config.columnWidth);
@@ -795,6 +815,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
white-space: ${value instanceof Date ? 'nowrap' : undefined};
position: relative;
background: ${backgroundColor || undefined};
padding-left: ${column.isChildColumn
? `${theme.gridUnit * 5}px`
: `${theme.gridUnit}px`};
`;
const cellBarStyles = css`
@@ -970,11 +993,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
alignItems: 'flex-end',
}}
>
<span data-column-name={col.id}>{label}</span>
<span data-column-name={col.id}>{displayLabel}</span>
<SortIcon column={col} />
</div>
</th>
),
Footer: totals ? (
i === 0 ? (
<th key={`footer-summary-${i}`}>
@@ -1024,9 +1048,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
],
);
const visibleColumnsMeta = useMemo(
() => filteredColumnsMeta.filter(col => col.config?.visible !== false),
[filteredColumnsMeta],
);
const columns = useMemo(
() => filteredColumnsMeta.map(getColumnConfigs),
[filteredColumnsMeta, getColumnConfigs],
() => visibleColumnsMeta.map(getColumnConfigs),
[visibleColumnsMeta, getColumnConfigs],
);
const handleServerPaginationChange = useCallback(

View File

@@ -494,6 +494,7 @@ const config: ControlPanelConfig = {
chart?.queriesResponse?.[0] ?? {};
let colnames: string[] = _colnames || [];
let coltypes: GenericDataType[] = _coltypes || [];
const childColumnMap: Record<string, boolean> = {};
if (timeComparisonStatus) {
/**
@@ -501,15 +502,27 @@ const config: ControlPanelConfig = {
*/
const updatedColnames: string[] = [];
const updatedColtypes: GenericDataType[] = [];
colnames.forEach((colname, index) => {
if (coltypes[index] === GenericDataType.Numeric) {
updatedColnames.push(
...generateComparisonColumns(colname),
);
updatedColtypes.push(...generateComparisonColumnTypes(4));
const comparisonColumns =
generateComparisonColumns(colname);
comparisonColumns.forEach((name, idx) => {
updatedColnames.push(name);
updatedColtypes.push(
...generateComparisonColumnTypes(4),
);
if (idx === 0 && name.startsWith('Main ')) {
childColumnMap[name] = false;
} else {
childColumnMap[name] = true;
}
});
} else {
updatedColnames.push(colname);
updatedColtypes.push(coltypes[index]);
childColumnMap[colname] = false;
}
});
@@ -517,7 +530,7 @@ const config: ControlPanelConfig = {
coltypes = updatedColtypes;
}
return {
columnsPropsObject: { colnames, coltypes },
columnsPropsObject: { colnames, coltypes, childColumnMap },
};
},
},

View File

@@ -49,6 +49,9 @@ export type TableColumnConfig = {
colorPositiveNegative?: boolean;
truncateLongCells?: boolean;
currencyFormat?: Currency;
visible?: boolean;
customColumnName?: string;
displayTypeIcon?: boolean;
};
export interface DataColumnMeta {
@@ -68,6 +71,7 @@ export interface DataColumnMeta {
isPercentMetric?: boolean;
isNumeric?: boolean;
config?: TableColumnConfig;
isChildColumn?: boolean;
}
export interface TableChartData {

View File

@@ -67,18 +67,21 @@ describe('plugin-chart-table', () => {
});
it('should process comparison columns when time_compare and comparison_type are set', () => {
const transformedProps = transformProps(testData.comparison);
// Check if comparison columns are processed
const comparisonColumns = transformedProps.columns.filter(
col =>
col.label === 'Main' ||
col.originalLabel === 'metric_1' ||
col.originalLabel === 'metric_2' ||
col.label === '#' ||
col.label === '△' ||
col.label === '%',
);
expect(comparisonColumns.length).toBeGreaterThan(0);
expect(comparisonColumns.some(col => col.label === 'Main')).toBe(true);
expect(
comparisonColumns.some(col => col.originalLabel === 'metric_1'),
).toBe(true);
expect(
comparisonColumns.some(col => col.originalLabel === 'metric_2'),
).toBe(true);
expect(comparisonColumns.some(col => col.label === '#')).toBe(true);
expect(comparisonColumns.some(col => col.label === '△')).toBe(true);
expect(comparisonColumns.some(col => col.label === '%')).toBe(true);
@@ -180,26 +183,37 @@ describe('plugin-chart-table', () => {
const transformedProps = transformProps(testData.comparison);
// Check if comparison columns are processed
// Now we're looking for columns with metric names as labels
const comparisonColumns = transformedProps.columns.filter(
col =>
col.label === 'Main' ||
col.originalLabel === 'metric_1' ||
col.originalLabel === 'metric_2' ||
col.label === '#' ||
col.label === '△' ||
col.label === '%',
);
expect(comparisonColumns.length).toBeGreaterThan(0);
expect(comparisonColumns.some(col => col.label === 'Main')).toBe(true);
expect(
comparisonColumns.some(col => col.originalLabel === 'metric_1'),
).toBe(true);
expect(
comparisonColumns.some(col => col.originalLabel === 'metric_2'),
).toBe(true);
expect(comparisonColumns.some(col => col.label === '#')).toBe(true);
expect(comparisonColumns.some(col => col.label === '△')).toBe(true);
expect(comparisonColumns.some(col => col.label === '%')).toBe(true);
// Verify originalLabel for metric_1 comparison columns
const mainMetric1 = transformedProps.columns.find(
col => col.key === 'Main metric_1',
const metric1Column = transformedProps.columns.find(
col =>
col.originalLabel === 'metric_1' &&
!col.key.startsWith('#') &&
!col.key.startsWith('△') &&
!col.key.startsWith('%'),
);
expect(mainMetric1).toBeDefined();
expect(mainMetric1?.originalLabel).toBe('metric_1');
expect(metric1Column).toBeDefined();
expect(metric1Column?.originalLabel).toBe('metric_1');
expect(metric1Column?.label).toBe('Main');
const hashMetric1 = transformedProps.columns.find(
col => col.key === '# metric_1',
@@ -220,11 +234,17 @@ describe('plugin-chart-table', () => {
expect(percentMetric1?.originalLabel).toBe('metric_1');
// Verify originalLabel for metric_2 comparison columns
const mainMetric2 = transformedProps.columns.find(
col => col.key === 'Main metric_2',
const metric2Column = transformedProps.columns.find(
col =>
col.originalLabel === 'metric_2' &&
!col.key.startsWith('#') &&
!col.key.startsWith('△') &&
!col.key.startsWith('%'),
);
expect(mainMetric2).toBeDefined();
expect(mainMetric2?.originalLabel).toBe('metric_2');
expect(metric2Column).toBeDefined();
expect(metric2Column?.originalLabel).toBe('metric_2');
expect(metric2Column?.label).toBe('Main');
const hashMetric2 = transformedProps.columns.find(
col => col.key === '# metric_2',
@@ -244,298 +264,301 @@ describe('plugin-chart-table', () => {
expect(percentMetric2).toBeDefined();
expect(percentMetric2?.originalLabel).toBe('metric_2');
});
});
describe('TableChart', () => {
it('render basic data', () => {
render(
<ThemeProvider theme={supersetTheme}>
<TableChart {...transformProps(testData.basic)} sticky={false} />,
</ThemeProvider>,
);
describe('TableChart', () => {
it('render basic data', () => {
render(
<ThemeProvider theme={supersetTheme}>
<TableChart {...transformProps(testData.basic)} sticky={false} />,
</ThemeProvider>,
);
const firstDataRow = screen.getAllByRole('rowgroup')[1];
const cells = firstDataRow.querySelectorAll('td');
expect(cells).toHaveLength(12);
expect(cells[0]).toHaveTextContent('2020-01-01 12:34:56');
expect(cells[1]).toHaveTextContent('Michael');
// number is not in `metrics` list, so it should output raw value
// (in real world Superset, this would mean the column is used in GROUP BY)
expect(cells[2]).toHaveTextContent('2467063');
// should not render column with `.` in name as `undefined`
expect(cells[3]).toHaveTextContent('foo');
expect(cells[6]).toHaveTextContent('2467');
expect(cells[8]).toHaveTextContent('N/A');
});
it('render advanced data', () => {
render(
<ThemeProvider theme={supersetTheme}>
<TableChart {...transformProps(testData.advanced)} sticky={false} />,
</ThemeProvider>,
);
const secondColumnHeader = screen.getByText('Sum of Num');
expect(secondColumnHeader).toBeInTheDocument();
expect(secondColumnHeader?.getAttribute('data-column-name')).toEqual('1');
const firstDataRow = screen.getAllByRole('rowgroup')[1];
const cells = firstDataRow.querySelectorAll('td');
expect(cells[0]).toHaveTextContent('Michael');
expect(cells[2]).toHaveTextContent('12.346%');
expect(cells[4]).toHaveTextContent('2.47k');
});
it('render advanced data with currencies', () => {
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps(testData.advancedWithCurrency)}
sticky={false}
/>
),
}),
);
const cells = document.querySelectorAll('td');
expect(document.querySelectorAll('th')[1]).toHaveTextContent(
'Sum of Num',
);
expect(cells[0]).toHaveTextContent('Michael');
expect(cells[2]).toHaveTextContent('12.346%');
expect(cells[4]).toHaveTextContent('$ 2.47k');
});
it('render raw data', () => {
const props = transformProps({
...testData.raw,
rawFormData: { ...testData.raw.rawFormData },
const firstDataRow = screen.getAllByRole('rowgroup')[1];
const cells = firstDataRow.querySelectorAll('td');
expect(cells).toHaveLength(12);
expect(cells[0]).toHaveTextContent('2020-01-01 12:34:56');
expect(cells[1]).toHaveTextContent('Michael');
// number is not in `metrics` list, so it should output raw value
// (in real world Superset, this would mean the column is used in GROUP BY)
expect(cells[2]).toHaveTextContent('2467063');
// should not render column with `.` in name as `undefined`
expect(cells[3]).toHaveTextContent('foo');
expect(cells[6]).toHaveTextContent('2467');
expect(cells[8]).toHaveTextContent('N/A');
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
const cells = document.querySelectorAll('td');
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
expect(cells[0]).toHaveTextContent('1234');
expect(cells[1]).toHaveTextContent('10000');
expect(cells[1]).toHaveTextContent('0');
});
it('render raw data with currencies', () => {
const props = transformProps({
...testData.raw,
rawFormData: {
...testData.raw.rawFormData,
column_config: {
num: {
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
it('render advanced data', () => {
render(
<ThemeProvider theme={supersetTheme}>
<TableChart {...transformProps(testData.advanced)} sticky={false} />
,
</ThemeProvider>,
);
const secondColumnHeader = screen.getByText('Sum of Num');
expect(secondColumnHeader).toBeInTheDocument();
expect(secondColumnHeader?.getAttribute('data-column-name')).toEqual(
'1',
);
const firstDataRow = screen.getAllByRole('rowgroup')[1];
const cells = firstDataRow.querySelectorAll('td');
expect(cells[0]).toHaveTextContent('Michael');
expect(cells[2]).toHaveTextContent('12.346%');
expect(cells[4]).toHaveTextContent('2.47k');
});
it('render advanced data with currencies', () => {
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps(testData.advancedWithCurrency)}
sticky={false}
/>
),
}),
);
const cells = document.querySelectorAll('td');
expect(document.querySelectorAll('th')[1]).toHaveTextContent(
'Sum of Num',
);
expect(cells[0]).toHaveTextContent('Michael');
expect(cells[2]).toHaveTextContent('12.346%');
expect(cells[4]).toHaveTextContent('$ 2.47k');
});
it('render raw data', () => {
const props = transformProps({
...testData.raw,
rawFormData: { ...testData.raw.rawFormData },
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
const cells = document.querySelectorAll('td');
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
expect(cells[0]).toHaveTextContent('1234');
expect(cells[1]).toHaveTextContent('10000');
expect(cells[1]).toHaveTextContent('0');
});
it('render raw data with currencies', () => {
const props = transformProps({
...testData.raw,
rawFormData: {
...testData.raw.rawFormData,
column_config: {
num: {
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
},
},
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
const cells = document.querySelectorAll('td');
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
expect(cells[0]).toHaveTextContent('$ 1.23k');
expect(cells[1]).toHaveTextContent('$ 10k');
expect(cells[2]).toHaveTextContent('$ 0');
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
const cells = document.querySelectorAll('td');
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
expect(cells[0]).toHaveTextContent('$ 1.23k');
expect(cells[1]).toHaveTextContent('$ 10k');
expect(cells[2]).toHaveTextContent('$ 0');
});
it('render small formatted data with currencies', () => {
const props = transformProps({
...testData.raw,
rawFormData: {
...testData.raw.rawFormData,
column_config: {
num: {
d3SmallNumberFormat: '.2r',
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
it('render small formatted data with currencies', () => {
const props = transformProps({
...testData.raw,
rawFormData: {
...testData.raw.rawFormData,
column_config: {
num: {
d3SmallNumberFormat: '.2r',
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
},
},
queriesData: [
{
...testData.raw.queriesData[0],
data: [
{
num: 1234,
},
{
num: 0.5,
},
{
num: 0.61234,
},
],
},
],
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
const cells = document.querySelectorAll('td');
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
expect(cells[0]).toHaveTextContent('$ 1.23k');
expect(cells[1]).toHaveTextContent('$ 0.50');
expect(cells[2]).toHaveTextContent('$ 0.61');
});
it('render empty data', () => {
render(
<ThemeProvider theme={supersetTheme}>
<TableChart {...transformProps(testData.empty)} sticky={false} />,
</ThemeProvider>,
);
expect(screen.getByText('No records found')).toBeInTheDocument();
});
it('render color with column color formatter', () => {
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps({
...testData.advanced,
rawFormData: {
...testData.advanced.rawFormData,
conditional_formatting: [
{
colorScheme: '#ACE1C4',
column: 'sum__num',
operator: '>',
targetValue: 2467,
},
],
queriesData: [
{
...testData.raw.queriesData[0],
data: [
{
num: 1234,
},
})}
/>
),
}),
);
{
num: 0.5,
},
{
num: 0.61234,
},
],
},
],
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
const cells = document.querySelectorAll('td');
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
'rgba(172, 225, 196, 1)',
);
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe('');
});
it('render cell without color', () => {
const dataWithEmptyCell = testData.advanced.queriesData[0];
dataWithEmptyCell.data.push({
__timestamp: null,
name: 'Noah',
sum__num: null,
'%pct_nice': 0.643,
'abc.com': 'bazzinga',
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
expect(cells[0]).toHaveTextContent('$ 1.23k');
expect(cells[1]).toHaveTextContent('$ 0.50');
expect(cells[2]).toHaveTextContent('$ 0.61');
});
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps({
...testData.advanced,
queriesData: [dataWithEmptyCell],
rawFormData: {
...testData.advanced.rawFormData,
conditional_formatting: [
{
colorScheme: '#ACE1C4',
column: 'sum__num',
operator: '<',
targetValue: 12342,
},
],
},
})}
/>
),
}),
);
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe(
'rgba(172, 225, 196, 0.812)',
);
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
'',
);
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
});
it('should display originalLabel in grouped headers', () => {
render(
<ThemeProvider theme={supersetTheme}>
<TableChart {...transformProps(testData.comparison)} sticky={false} />
</ThemeProvider>,
);
it('render empty data', () => {
render(
<ThemeProvider theme={supersetTheme}>
<TableChart {...transformProps(testData.empty)} sticky={false} />,
</ThemeProvider>,
);
expect(screen.getByText('No records found')).toBeInTheDocument();
});
const groupHeaders = screen.getAllByRole('columnheader');
expect(groupHeaders[0]).toHaveTextContent('metric_1');
expect(groupHeaders[1]).toHaveTextContent('metric_2');
});
});
it('render color with column color formatter', () => {
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps({
...testData.advanced,
rawFormData: {
...testData.advanced.rawFormData,
conditional_formatting: [
{
colorScheme: '#ACE1C4',
column: 'sum__num',
operator: '>',
targetValue: 2467,
},
],
},
})}
/>
),
}),
);
it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
const props = transformProps({
...testData.raw,
rawFormData: { ...testData.raw.rawFormData },
});
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
'rgba(172, 225, 196, 1)',
);
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe('');
});
props.columns[0].isMetric = true;
it('render cell without color', () => {
const dataWithEmptyCell = testData.advanced.queriesData[0];
dataWithEmptyCell.data.push({
__timestamp: null,
name: 'Noah',
sum__num: null,
'%pct_nice': 0.643,
'abc.com': 'bazzinga',
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
let cells = document.querySelectorAll('div.cell-bar');
cells.forEach(cell => {
expect(cell).toHaveClass('positive');
});
props.columns[0].isMetric = false;
props.columns[0].isPercentMetric = true;
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps({
...testData.advanced,
queriesData: [dataWithEmptyCell],
rawFormData: {
...testData.advanced.rawFormData,
conditional_formatting: [
{
colorScheme: '#ACE1C4',
column: 'sum__num',
operator: '<',
targetValue: 12342,
},
],
},
})}
/>
),
}),
);
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe(
'rgba(172, 225, 196, 0.812)',
);
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
'',
);
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
});
it('should display originalLabel in grouped headers', () => {
const props = transformProps(testData.comparison);
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
cells = document.querySelectorAll('div.cell-bar');
cells.forEach(cell => {
expect(cell).toHaveClass('positive');
});
render(
<ThemeProvider theme={supersetTheme}>
<TableChart {...props} sticky={false} />
</ThemeProvider>,
);
const groupHeaders = screen.getAllByRole('columnheader');
expect(groupHeaders.length).toBeGreaterThan(0);
const hasMetricHeaders = groupHeaders.some(
header =>
header.textContent &&
(header.textContent.includes('metric') ||
header.textContent.includes('Metric')),
);
expect(hasMetricHeaders).toBe(true);
});
props.showCellBars = false;
it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
const props = transformProps({
...testData.raw,
rawFormData: { ...testData.raw.rawFormData },
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
cells = document.querySelectorAll('td');
props.columns[0].isMetric = true;
cells.forEach(cell => {
expect(cell).toHaveClass('test-c7w8t3');
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
let cells = document.querySelectorAll('div.cell-bar');
cells.forEach(cell => {
expect(cell).toHaveClass('positive');
});
props.columns[0].isMetric = false;
props.columns[0].isPercentMetric = true;
props.columns[0].isPercentMetric = false;
props.columns[0].isMetric = true;
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
cells = document.querySelectorAll('div.cell-bar');
cells.forEach(cell => {
expect(cell).toHaveClass('positive');
});
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
cells = document.querySelectorAll('td');
cells.forEach(cell => {
expect(cell).toHaveClass('test-c7w8t3');
props.showCellBars = false;
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
cells = document.querySelectorAll('td');
props.columns[0].isPercentMetric = false;
props.columns[0].isMetric = true;
render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
cells = document.querySelectorAll('td');
});
});
});
});

View File

@@ -34,7 +34,11 @@ import ControlHeader from '../../ControlHeader';
export type ColumnConfigControlProps<T extends ColumnConfig> =
ControlComponentProps<Record<string, T>> & {
columnsPropsObject?: { colnames: string[]; coltypes: GenericDataType[] };
columnsPropsObject?: {
colnames: string[];
coltypes: GenericDataType[];
childColumnMap?: Record<string, boolean>;
};
configFormLayout?: ColumnConfigFormLayout;
appliedColumnNames?: string[];
width?: number | string;
@@ -82,10 +86,12 @@ export default function ColumnConfigControl<T extends ColumnConfig>({
name: COLUMN_NAME_ALIASES[col] || col,
type: coltypes?.[idx],
config: value?.[col] || {},
isChildColumn: columnsPropsObject?.childColumnMap?.[col] ?? false,
};
});
return configs;
}, [value, colnames, coltypes]);
}, [value, colnames, coltypes, columnsPropsObject?.childColumnMap]);
const [showAllColumns, setShowAllColumns] = useState(false);
const getColumnInfo = (col: string) => columnConfigs[col] || {};
@@ -113,6 +119,8 @@ export default function ColumnConfigControl<T extends ColumnConfig>({
? colnames.slice(0, MAX_NUM_COLS)
: colnames;
const columnsWithChildInfo = cols.map(col => getColumnInfo(col));
return (
<>
<ControlHeader {...props} />
@@ -122,12 +130,30 @@ export default function ColumnConfigControl<T extends ColumnConfig>({
borderRadius: theme.gridUnit,
}}
>
{cols.map(col => (
{columnsWithChildInfo.map(col => (
<ColumnConfigItem
key={col}
column={getColumnInfo(col)}
onChange={config => setColumnConfig(col, config as T)}
configFormLayout={configFormLayout}
key={col.name}
column={col}
onChange={config => setColumnConfig(col.name, config as T)}
configFormLayout={
col.isChildColumn
? ({
[col.type ?? GenericDataType.String]: [
{
tab: 'General',
children: [
['customColumnName'],
['displayTypeIcon'],
['visible'],
],
},
...(configFormLayout?.[
col.type ?? GenericDataType.String
] ?? []),
],
} as ColumnConfigFormLayout)
: configFormLayout
}
width={width}
height={height}
/>

View File

@@ -17,8 +17,9 @@
* under the License.
*/
import { memo } from 'react';
import { useTheme } from '@superset-ui/core';
import { css, useTheme } from '@superset-ui/core';
import Popover from 'src/components/Popover';
import { Icons } from 'src/components/Icons';
import { ColumnTypeLabel } from '@superset-ui/chart-controls';
import ColumnConfigPopover, {
ColumnConfigPopoverProps,
@@ -35,6 +36,59 @@ export default memo(function ColumnConfigItem({
}: ColumnConfigItemProps) {
const { colors, gridUnit } = useTheme();
const caretWidth = gridUnit * 6;
const outerContainerStyle = css({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
padding: `${gridUnit}px ${2 * gridUnit}px`,
borderBottom: `1px solid ${colors.grayscale.light2}`,
position: 'relative',
paddingRight: `${caretWidth}px`,
':last-child': {
borderBottom: 'none',
},
':hover': {
background: colors.grayscale.light4,
},
'> .fa': {
color: colors.grayscale.light2,
},
':hover > .fa': {
color: colors.grayscale.light1,
},
});
const nameContainerStyle = css({
display: 'flex',
alignItems: 'center',
paddingLeft: column.isChildColumn ? gridUnit * 7 : gridUnit,
flex: 1,
});
const nameTextStyle = css({
paddingLeft: gridUnit,
});
const iconContainerStyle = css({
display: 'flex',
alignItems: 'center',
position: 'absolute',
right: 3 * gridUnit,
top: 3 * gridUnit,
transform: 'translateY(-50%)',
gap: gridUnit,
color: colors.grayscale.light1,
});
const theme = useTheme();
const caretIconStyle = css({
fontSize: `${theme.typography.sizes.s}px`,
fontWeight: theme.typography.weights.normal,
color: theme.colors.grayscale.light1,
});
return (
<Popover
title={column.name}
@@ -50,41 +104,21 @@ export default memo(function ColumnConfigItem({
overlayInnerStyle={{ width, height }}
overlayClassName="column-config-popover"
>
<div
css={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
padding: `${gridUnit}px ${2 * gridUnit}px`,
borderBottom: `1px solid ${colors.grayscale.light2}`,
position: 'relative',
paddingRight: caretWidth,
'&:last-child': {
borderBottom: 'none',
},
'&:hover': {
background: colors.grayscale.light4,
},
'> .fa': {
color: colors.grayscale.light2,
},
'&:hover > .fa': {
color: colors.grayscale.light1,
},
}}
>
<ColumnTypeLabel type={column.type} />
{column.name}
{/* TODO: Remove fa-icon */}
{/* eslint-disable-next-line icons/no-fa-icons-usage */}
<i
className="fa fa-caret-right"
css={{
position: 'absolute',
right: 3 * gridUnit,
top: 3 * gridUnit,
}}
/>
<div css={outerContainerStyle}>
<div css={nameContainerStyle}>
<ColumnTypeLabel type={column.type} />
<span css={nameTextStyle}>{column.name}</span>
</div>
<div css={iconContainerStyle}>
{column.isChildColumn && column.config?.visible === false && (
<Icons.EyeInvisibleOutlined
iconSize="s"
iconColor={colors.grayscale.base}
/>
)}
<Icons.CaretRightOutlined css={caretIconStyle} />
</div>
</div>
</Popover>
);

View File

@@ -38,8 +38,10 @@ export type SharedColumnConfigProp =
| 'horizontalAlign'
| 'truncateLongCells'
| 'showCellBars'
| 'currencyFormat'
| 'visible';
| 'visible'
| 'customColumnName'
| 'displayTypeIcon'
| 'currencyFormat';
const d3NumberFormat: ControlFormItemSpec<'Select'> = {
allowNewOptions: true,
@@ -137,6 +139,21 @@ const colorPositiveNegative: ControlFormItemSpec<'Checkbox'> = {
debounceDelay: 200,
};
const customColumnName: ControlFormItemSpec<'Input'> = {
controlType: 'Input',
label: t('Display column name'),
description: t('Custom column name (leave blank for default)'),
debounceDelay: 200,
};
const displayTypeIcon: ControlFormItemSpec<'Checkbox'> = {
controlType: 'Checkbox',
label: t('Display type icon'),
description: t('Whether to display the type icon (#, Δ, %)'),
defaultValue: true,
debounceDelay: 200,
};
const truncateLongCells: ControlFormItemSpec<'Checkbox'> = {
controlType: 'Checkbox',
label: t('Truncate Cells'),
@@ -156,7 +173,7 @@ const currencyFormat: ControlFormItemSpec<'CurrencyControl'> = {
const visible: ControlFormItemSpec<'Checkbox'> = {
controlType: 'Checkbox',
label: t('Display in chart'),
label: t('Display column in the chart'),
description: t('Whether to display in the chart'),
defaultValue: true,
debounceDelay: 200,
@@ -177,6 +194,8 @@ export const SHARED_COLUMN_CONFIG_PROPS = {
d3TimeFormat,
fractionDigits,
columnWidth,
customColumnName,
displayTypeIcon,
truncateLongCells,
horizontalAlign,
showCellBars,
@@ -196,7 +215,7 @@ export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = {
],
[GenericDataType.Numeric]: [
{
tab: t('Display'),
tab: t('Column Settings'),
children: [
[
'columnWidth',

View File

@@ -40,6 +40,7 @@ export type ColumnConfig = {
* formatting.
*/
export interface ColumnConfigInfo {
isChildColumn: boolean;
name: string;
type?: GenericDataType;
config: JsonObject;