fix(theming): fix TimeTable chart issues (#34868)

This commit is contained in:
Gabriel Torres Ruiz
2025-09-02 07:48:13 -03:00
committed by GitHub
parent 4695be5cc5
commit d183969744
64 changed files with 3995 additions and 709 deletions

View File

@@ -1,243 +0,0 @@
/* eslint-disable class-methods-use-this */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
import {
formatNumber,
formatTime,
getTextDimension,
useTheme,
} from '@superset-ui/core';
import { GridRows } from '@visx/grid';
import { LinearScaleConfig, scaleLinear } from '@visx/scale';
import { AxisScaleOutput } from '@visx/axis';
import {
Axis,
LineSeries,
Tooltip,
XYChart,
buildChartTheme,
} from '@visx/xychart';
import { extendedDayjs } from '@superset-ui/core/utils/dates';
interface Props {
ariaLabel: string;
dataKey: string;
className?: string;
data: Array<number>;
entries: Array<any>;
height: number;
numberFormat: string;
dateFormat: string;
renderTooltip: ({ index }: { index: number }) => ReactNode;
showYAxis: boolean;
width: number;
yAxisBounds: Array<number | undefined>;
}
const MARGIN = {
top: 8,
right: 8,
bottom: 8,
left: 8,
};
function getSparklineTextWidth(text: string) {
return (
getTextDimension({
text,
style: {
fontSize: '12px',
fontWeight: 200,
letterSpacing: 0.4,
},
}).width + 5
);
}
function isValidBoundValue(value?: number | string) {
return (
value !== null &&
value !== undefined &&
value !== '' &&
!Number.isNaN(value)
);
}
const SparklineCell = ({
ariaLabel,
dataKey,
data,
width = 300,
height = 50,
numberFormat = '',
dateFormat = '',
yAxisBounds = [undefined, undefined],
showYAxis = false,
entries = [],
}: Props) => {
const theme = useTheme();
const xyTheme = buildChartTheme({
backgroundColor: `${theme.colorBgContainer}`,
colors: [`${theme.colorText}`],
gridColor: `${theme.colorSplit}`,
gridColorDark: `${theme.colorBorder}`,
tickLength: 6,
});
const yScaleConfig: LinearScaleConfig<AxisScaleOutput> = {
type: 'linear',
zero: false,
};
let hasMinBound = false;
let hasMaxBound = false;
let min: number = data.reduce(
(acc, current) => Math.min(acc, current),
data[0],
);
let max: number = data.reduce(
(acc, current) => Math.max(acc, current),
data[0],
);
if (yAxisBounds) {
const [minBound, maxBound] = yAxisBounds;
hasMinBound = isValidBoundValue(minBound);
if (hasMinBound) {
if (minBound !== undefined && minBound <= 0) {
yScaleConfig.zero = true;
}
min = minBound || min;
}
hasMaxBound = isValidBoundValue(maxBound);
if (hasMaxBound) {
max = maxBound || max;
}
yScaleConfig.domain = [min, max];
}
let minLabel: string;
let maxLabel: string;
let labelLength = 0;
if (showYAxis) {
yScaleConfig.domain = [min, max];
minLabel = formatNumber(numberFormat, min);
maxLabel = formatNumber(numberFormat, max);
labelLength = Math.max(
getSparklineTextWidth(minLabel),
getSparklineTextWidth(maxLabel),
);
}
const margin = {
...MARGIN,
right: MARGIN.right + labelLength,
};
const innerWidth = width - margin.left - margin.right;
const chartData = data.map((num, idx) => ({
x: idx,
y: num,
}));
const xAccessor = (d: any) => d.x;
const yAccessor = (d: any) => d.y;
return (
<>
<XYChart
accessibilityLabel={ariaLabel}
width={width}
height={height}
margin={margin}
yScale={{
...yScaleConfig,
}}
xScale={{ type: 'band', paddingInner: 0.5 }}
theme={xyTheme}
>
{showYAxis && (
<Axis
hideAxisLine
hideTicks
numTicks={2}
orientation="right"
tickFormat={(d: any) => formatNumber(numberFormat, d)}
tickValues={[min, max]}
/>
)}
{showYAxis && min !== undefined && max !== undefined && (
<GridRows
left={margin.left}
scale={scaleLinear({
range: [height - margin.top, margin.bottom],
domain: [min, max],
})}
width={innerWidth}
strokeDasharray="3 3"
stroke={`${theme.colorSplit}`}
tickValues={[min, max]}
/>
)}
<LineSeries
data={chartData}
dataKey={dataKey}
xAccessor={xAccessor}
yAccessor={yAccessor}
/>
<Tooltip
glyphStyle={{ strokeWidth: 1 }}
showDatumGlyph
showVerticalCrosshair
snapTooltipToDatumX
snapTooltipToDatumY
verticalCrosshairStyle={{
stroke: `${theme.colorText}`,
strokeDasharray: '3 3',
strokeWidth: 1,
}}
renderTooltip={({ tooltipData }) => {
const idx = tooltipData?.datumByKey[dataKey].index;
return (
<div>
<strong>
{idx !== undefined && formatNumber(numberFormat, data[idx])}
</strong>
<div>
{idx !== undefined &&
formatTime(
dateFormat,
extendedDayjs.utc(entries[idx].time).toDate(),
)}
</div>
</div>
);
}}
/>
</XYChart>
<style>
{`svg:not(:root) {
overflow: visible;
}`}
</style>
</>
);
};
export default SparklineCell;

View File

@@ -1,344 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import PropTypes from 'prop-types';
import Mustache from 'mustache';
import { scaleLinear } from 'd3-scale';
import {
InfoTooltip,
TableView,
Typography,
} from '@superset-ui/core/components';
import { styled, t } from '@superset-ui/core';
import { MetricOption } from '@superset-ui/chart-controls';
import sortNumericValues from 'src/utils/sortNumericValues';
import FormattedNumber from './FormattedNumber';
import SparklineCell from './SparklineCell';
const ACCESSIBLE_COLOR_BOUNDS = ['#ca0020', '#0571b0'];
const sortNumberWithMixedTypes = (rowA, rowB, columnId, descending) =>
sortNumericValues(
rowA.values[columnId].props['data-value'],
rowB.values[columnId].props['data-value'],
{ descending, nanTreatment: 'asSmallest' },
) *
// react-table sort function always expects -1 for smaller number
(descending ? -1 : 1);
function colorFromBounds(value, bounds, colorBounds = ACCESSIBLE_COLOR_BOUNDS) {
if (bounds) {
const [min, max] = bounds;
const [minColor, maxColor] = colorBounds;
if (min !== null && max !== null) {
const colorScale = scaleLinear()
.domain([min, (max + min) / 2, max])
.range([minColor, 'grey', maxColor]);
return colorScale(value);
}
if (min !== null) {
return value >= min ? maxColor : minColor;
}
if (max !== null) {
return value < max ? maxColor : minColor;
}
}
return null;
}
const propTypes = {
className: PropTypes.string,
height: PropTypes.number,
// Example
// {'2018-04-14 00:00:00': { 'SUM(metric_value)': 80031779.40047 }}
data: PropTypes.objectOf(PropTypes.objectOf(PropTypes.number)).isRequired,
columnConfigs: PropTypes.arrayOf(
PropTypes.shape({
colType: PropTypes.string,
comparisonType: PropTypes.string,
d3format: PropTypes.string,
key: PropTypes.string,
label: PropTypes.string,
timeLag: PropTypes.number,
}),
).isRequired,
rows: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.shape({
label: PropTypes.string,
}),
PropTypes.shape({
metric_name: PropTypes.string,
}),
]),
).isRequired,
rowType: PropTypes.oneOf(['column', 'metric']).isRequired,
url: PropTypes.string,
};
const defaultProps = {
className: '',
height: undefined,
url: '',
};
// @z-index-above-dashboard-charts + 1 = 11
const TimeTableStyles = styled.div`
height: ${props => props.height}px;
overflow: auto;
th {
z-index: 11 !important; // to cover sparkline
}
`;
const TimeTable = ({
className,
height,
data,
columnConfigs,
rowType,
rows,
url,
}) => {
const memoizedColumns = useMemo(
() => [
{ accessor: 'metric', Header: t('Metric') },
...columnConfigs.map((columnConfig, i) => ({
accessor: columnConfig.key,
cellProps: columnConfig.colType === 'spark' && {
style: { width: '1%' },
},
Header: () => (
<>
{columnConfig.label}{' '}
{columnConfig.tooltip && (
<InfoTooltip
tooltip={columnConfig.tooltip}
label={`tt-col-${i}`}
placement="top"
/>
)}
</>
),
sortType: sortNumberWithMixedTypes,
})),
],
[columnConfigs],
);
const memoizedRows = useMemo(() => {
const renderSparklineCell = (valueField, column, entries) => {
let sparkData;
if (column.timeRatio) {
// Period ratio sparkline
sparkData = [];
for (let i = column.timeRatio; i < entries.length; i += 1) {
const prevData = entries[i - column.timeRatio][valueField];
if (prevData && prevData !== 0) {
sparkData.push(entries[i][valueField] / prevData);
} else {
sparkData.push(null);
}
}
} else {
sparkData = entries.map(d => d[valueField]);
}
return (
<SparklineCell
ariaLabel={`spark-${valueField}`}
width={parseInt(column.width, 10) || 300}
height={parseInt(column.height, 10) || 50}
data={sparkData}
dataKey={`spark-${valueField}`}
dateFormat={column.dateFormat}
numberFormat={column.d3format}
yAxisBounds={column.yAxisBounds}
showYAxis={column.showYAxis}
entries={entries}
/>
);
};
const renderValueCell = (valueField, column, reversedEntries) => {
const recent = reversedEntries[0][valueField];
let v;
let errorMsg;
if (column.colType === 'time') {
// If time lag is negative, we compare from the beginning of the data
const timeLag = column.timeLag || 0;
const totalLag = Object.keys(reversedEntries).length;
if (Math.abs(timeLag) >= totalLag) {
errorMsg = `The time lag set at ${timeLag} is too large for the length of data at ${reversedEntries.length}. No data available.`;
} else if (timeLag < 0) {
v = reversedEntries[totalLag + timeLag][valueField];
} else {
v = reversedEntries[timeLag][valueField];
}
if (typeof v === 'number' && typeof recent === 'number') {
if (column.comparisonType === 'diff') {
v = recent - v;
} else if (column.comparisonType === 'perc') {
v = recent / v;
} else if (column.comparisonType === 'perc_change') {
v = recent / v - 1;
}
} else {
v = null;
}
} else if (column.colType === 'contrib') {
// contribution to column total
v =
recent /
Object.keys(reversedEntries[0])
.map(k => (k !== 'time' ? reversedEntries[0][k] : null))
.reduce((a, b) => a + b);
} else if (column.colType === 'avg') {
// Average over the last {timeLag}
v = null;
if (reversedEntries.length > 0) {
const stats = reversedEntries.slice(undefined, column.timeLag).reduce(
function ({ count, sum }, entry) {
return entry[valueField] !== undefined &&
entry[valueField] !== null
? { count: count + 1, sum: sum + entry[valueField] }
: { count, sum };
},
{ count: 0, sum: 0 },
);
if (stats.count > 0) {
v = stats.sum / stats.count;
}
}
}
const color = colorFromBounds(v, column.bounds);
return (
<span
key={column.key}
data-value={v}
css={theme =>
color && {
boxShadow: `inset 0px -2.5px 0px 0px ${color}`,
borderRight: `2px solid ${theme.colorBorderSecondary}`,
}
}
>
{errorMsg || (
<span style={{ color }}>
<FormattedNumber num={v} format={column.d3format} />
</span>
)}
</span>
);
};
const renderLeftCell = row => {
const context = { metric: row };
const fullUrl = url ? Mustache.render(url, context) : null;
if (rowType === 'column') {
const column = row;
if (fullUrl) {
return (
<Typography.Link
href={fullUrl}
rel="noopener noreferrer"
target="_blank"
>
{column.label}
</Typography.Link>
);
}
return column.label;
}
return (
<MetricOption
metric={row}
url={fullUrl}
showFormula={false}
openInNewWindow
/>
);
};
const entries = Object.keys(data)
.sort()
.map(time => ({ ...data[time], time }));
const reversedEntries = entries.concat().reverse();
return rows.map(row => {
const valueField = row.label || row.metric_name;
const cellValues = columnConfigs.reduce((acc, columnConfig) => {
if (columnConfig.colType === 'spark') {
return {
...acc,
[columnConfig.key]: renderSparklineCell(
valueField,
columnConfig,
entries,
),
};
}
return {
...acc,
[columnConfig.key]: renderValueCell(
valueField,
columnConfig,
reversedEntries,
),
};
}, {});
return { ...row, ...cellValues, metric: renderLeftCell(row) };
});
}, [columnConfigs, data, rowType, rows, url]);
const defaultSort =
rowType === 'column' && columnConfigs.length
? [
{
id: columnConfigs[0].key,
desc: 'true',
},
]
: [];
return (
<TimeTableStyles
data-test="time-table"
className={className}
height={height}
>
<TableView
className="table-no-hover"
columns={memoizedColumns}
data={memoizedRows}
initialSortBy={defaultSort}
withPagination={false}
/>
</TimeTableStyles>
);
};
TimeTable.propTypes = propTypes;
TimeTable.defaultProps = defaultProps;
export default TimeTable;

View File

@@ -0,0 +1,172 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from '@superset-ui/core/spec';
import TimeTable from './TimeTable';
const mockData = {
'2003-01-01 00:00:00': {
'SUM(sales)': 3516979.54,
},
'2004-01-01 00:00:00': {
'SUM(sales)': 4724162.6,
},
'2005-01-01 00:00:00': {
'SUM(sales)': 1791486.71,
},
};
const mockRows = [
{
aggregate: 'SUM',
column: {
column_name: 'sales',
id: 745,
type: 'DOUBLE PRECISION',
},
label: 'SUM(sales)',
optionName: 'metric_test',
},
];
const mockColumnConfigs = [
{
bounds: [null, null],
colType: 'spark',
comparisonType: '',
d3format: '',
dateFormat: '',
height: '',
key: 'test-sparkline-key',
label: 'Time series columns',
showYAxis: false,
timeLag: 0,
timeRatio: '',
tooltip: '',
width: '',
yAxisBounds: [null, null],
},
];
const defaultProps = {
className: '',
height: 400,
data: mockData,
columnConfigs: mockColumnConfigs,
rows: mockRows,
rowType: 'metric' as const,
url: '',
};
test('should render TimeTable component', () => {
const { container } = render(<TimeTable {...defaultProps} />);
expect(container).toBeInTheDocument();
});
test('should render table headers', () => {
render(<TimeTable {...defaultProps} />);
expect(screen.getByText('Metric')).toBeInTheDocument();
expect(screen.getByText('Time series columns')).toBeInTheDocument();
});
test('should render table with data rows', () => {
render(<TimeTable {...defaultProps} />);
const tableRows = screen.getAllByTestId('table-row');
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getAllByRole('columnheader')).toHaveLength(2);
expect(tableRows.length).toBeGreaterThan(0);
});
test('should render sparkline data in table cells', () => {
render(<TimeTable {...defaultProps} />);
const tableCells = screen.getAllByTestId('table-row-cell');
expect(tableCells.length).toBeGreaterThan(0);
});
test('should handle columns with proper id and accessor properties', () => {
render(<TimeTable {...defaultProps} />);
const table = screen.getByRole('table');
expect(table).toBeInTheDocument();
expect(screen.getAllByTestId('table-row')).toHaveLength(1);
expect(screen.getAllByTestId('table-row-cell')).toHaveLength(2);
});
test('should render with empty data gracefully', () => {
const emptyProps = {
...defaultProps,
data: {},
rows: [],
};
const { container } = render(<TimeTable {...emptyProps} />);
expect(container).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
});
test('should render with multiple metrics', () => {
const multipleMetricsProps = {
...defaultProps,
rows: [
...mockRows,
{
aggregate: 'AVG',
column: {
column_name: 'price',
id: 746,
type: 'DOUBLE PRECISION',
},
label: 'AVG(price)',
optionName: 'metric_test_2',
},
],
};
render(<TimeTable {...multipleMetricsProps} />);
expect(screen.getAllByTestId('table-row')).toHaveLength(2);
});
test('should handle column type sparkline correctly', () => {
render(<TimeTable {...defaultProps} />);
const columnHeaders = screen.getAllByRole('columnheader');
expect(screen.getByRole('table')).toBeInTheDocument();
expect(columnHeaders).toHaveLength(2);
expect(screen.getByText('Time series columns')).toBeInTheDocument();
});
test('should not render empty table due to missing column id property', () => {
render(<TimeTable {...defaultProps} />);
const table = screen.getByRole('table');
const dataRows = screen.getAllByTestId('table-row');
const dataCells = screen.getAllByTestId('table-row-cell');
expect(table).toBeInTheDocument();
expect(screen.getAllByRole('columnheader')).toHaveLength(2);
expect(dataRows).toHaveLength(1);
expect(dataCells).toHaveLength(2);
});

View File

@@ -0,0 +1,144 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo, ReactNode } from 'react';
import { InfoTooltip, TableView } from '@superset-ui/core/components';
import { styled, t } from '@superset-ui/core';
import { sortNumberWithMixedTypes, processTimeTableData } from './utils';
import { ValueCell, LeftCell, Sparkline } from './components';
import type { TimeTableProps } from './types';
// @z-index-above-dashboard-charts + 1 = 11
const TimeTableStyles = styled.div<{ height?: number }>`
height: ${props => props.height}px;
overflow: auto;
th {
z-index: 11 !important; // to cover sparkline
}
`;
const TimeTable = ({
className = '',
height,
data,
columnConfigs,
rowType,
rows,
url = '',
}: TimeTableProps) => {
const memoizedColumns = useMemo(
() => [
{
accessor: 'metric',
Header: t('Metric'),
id: 'metric', // REQUIRED: TableView needs both accessor and id to render rows
},
...columnConfigs.map((columnConfig, i) => ({
accessor: columnConfig.key,
id: columnConfig.key, // REQUIRED: TableView needs both accessor and id to render rows
cellProps: columnConfig.colType === 'spark' && {
style: { width: '1%' },
},
Header: () => (
<>
{columnConfig.label}{' '}
{columnConfig.tooltip && (
<InfoTooltip
tooltip={columnConfig.tooltip}
label={`tt-col-${i}`}
placement="top"
/>
)}
</>
),
sortType: sortNumberWithMixedTypes,
})),
],
[columnConfigs],
);
const memoizedRows = useMemo(() => {
const { entries, reversedEntries } = processTimeTableData(data);
return rows.map(row => {
const valueField = row.label || row.metric_name || '';
const cellValues = columnConfigs.reduce<Record<string, ReactNode>>(
(acc, columnConfig) => {
if (columnConfig.colType === 'spark') {
return {
...acc,
[columnConfig.key]: (
<Sparkline
valueField={valueField}
column={columnConfig}
entries={entries}
/>
),
};
}
return {
...acc,
[columnConfig.key]: (
<ValueCell
valueField={valueField}
column={columnConfig}
reversedEntries={reversedEntries}
/>
),
};
},
{},
);
return {
...row,
...cellValues,
metric: <LeftCell row={row} rowType={rowType} url={url} />,
};
});
}, [columnConfigs, data, rowType, rows, url]);
const defaultSort =
rowType === 'column' && columnConfigs.length
? [
{
id: columnConfigs[0].key,
desc: true,
},
]
: [];
return (
<TimeTableStyles
data-test="time-table"
className={className}
height={height}
>
<TableView
className="table-no-hover"
columns={memoizedColumns}
data={memoizedRows}
initialSortBy={defaultSort}
withPagination={false}
/>
</TimeTableStyles>
);
};
export default TimeTable;

View File

@@ -0,0 +1,96 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from '@superset-ui/core/spec';
import FormattedNumber from './FormattedNumber';
test('should render number without format', () => {
render(<FormattedNumber num={12345} />);
expect(screen.getByText('12345')).toBeInTheDocument();
});
test('should render formatted number with format string', () => {
render(<FormattedNumber num={12345.6789} format=".2f" />);
expect(screen.getByText('12345.68')).toBeInTheDocument();
});
test('should render with percentage format', () => {
render(<FormattedNumber num={0.456} format=".1%" />);
expect(screen.getByText('45.6%')).toBeInTheDocument();
});
test('should render with thousands separator', () => {
render(<FormattedNumber num={1234567} format="," />);
expect(screen.getByText('1,234,567')).toBeInTheDocument();
});
test('should render zero when num is undefined', () => {
render(<FormattedNumber format=".2f" />);
expect(screen.getByText('0.00')).toBeInTheDocument();
});
test('should render zero without format when num is undefined', () => {
render(<FormattedNumber />);
expect(screen.getByText('0')).toBeInTheDocument();
});
test('should have title attribute with original number when formatted', () => {
render(<FormattedNumber num={12345.6789} format=".2f" />);
const span = screen.getByText('12345.68');
expect(span).toHaveAttribute('title', '12345.6789');
});
test('should not have title attribute when no format is applied', () => {
render(<FormattedNumber num={12345} />);
const span = screen.getByText('12345');
expect(span).not.toHaveAttribute('title');
});
test('should handle string numbers', () => {
render(<FormattedNumber num="12345" />);
expect(screen.getByText('12345')).toBeInTheDocument();
});
test('should handle string numbers with format', () => {
render(<FormattedNumber num="12345.6789" format=".2f" />);
expect(screen.getByText('12345.68')).toBeInTheDocument();
});
test('should handle negative numbers', () => {
render(<FormattedNumber num={-12345.67} format=".2f" />);
expect(screen.getByText('-12345.67')).toBeInTheDocument();
});
test('should handle very large numbers', () => {
render(<FormattedNumber num={1.234e12} format=".3s" />);
expect(screen.getByText('1.23T')).toBeInTheDocument();
});
test('should handle invalid string numbers with format', () => {
render(<FormattedNumber num="invalid" format=".2f" />);
expect(screen.getByText('0.00')).toBeInTheDocument();
});
test('should handle null values', () => {
render(<FormattedNumber num={null} />);
expect(screen.getByText('0')).toBeInTheDocument();
});

View File

@@ -17,18 +17,23 @@
* under the License.
*/
import { formatNumber } from '@superset-ui/core';
import { parseToNumber } from '../../utils';
interface FormattedNumberProps {
num?: string | number;
num?: string | number | null;
format?: string;
}
function FormattedNumber({ num = 0, format }: FormattedNumberProps) {
if (format) {
// @ts-expect-error formatNumber can actually accept strings, even though it's not typed as such
return <span title={`${num}`}>{formatNumber(format, num)}</span>;
}
return <span>{num}</span>;
}
const FormattedNumber = ({ num = 0, format }: FormattedNumberProps) => {
const displayNum = num ?? 0;
const numericValue = parseToNumber(num);
if (format)
return (
<span title={`${displayNum}`}>{formatNumber(format, numericValue)}</span>
);
return <span>{displayNum}</span>;
};
export default FormattedNumber;

View File

@@ -0,0 +1,19 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { default } from './FormattedNumber';

View File

@@ -0,0 +1,142 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from '@superset-ui/core/spec';
import LeftCell from './LeftCell';
describe('LeftCell', () => {
test('should render column label for column row type', () => {
const columnRow = {
label: 'Test Column',
column_name: 'test_column',
};
render(<LeftCell row={columnRow} rowType="column" />);
expect(screen.getByText('Test Column')).toBeInTheDocument();
});
test('should render link for column row type with URL', () => {
const columnRow = {
label: 'Test Column',
column_name: 'test_column',
};
const url = 'http://example.com/{{metric.column_name}}';
render(<LeftCell row={columnRow} rowType="column" url={url} />);
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'http://example.com/test_column');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveTextContent('Test Column');
});
test('should render MetricOption for metric row type', () => {
const metricRow = {
metric_name: 'SUM(sales)',
label: 'Sum of Sales',
verbose_name: 'Total Sales',
description: 'Sum of all sales',
};
const { container } = render(<LeftCell row={metricRow} rowType="metric" />);
expect(container.firstChild).toBeInTheDocument();
});
test('should render MetricOption with URL for metric row type', () => {
const metricRow = {
metric_name: 'SUM(sales)',
label: 'Sum of Sales',
};
const url = 'http://example.com/metrics/{{metric.metric_name}}';
const { container } = render(
<LeftCell row={metricRow} rowType="metric" url={url} />,
);
expect(container.firstChild).toBeInTheDocument();
});
test('should handle empty column label', () => {
const columnRow = {
label: '',
column_name: 'empty_column',
};
const { container } = render(<LeftCell row={columnRow} rowType="column" />);
expect(container).toBeTruthy();
expect(container).toHaveTextContent('');
});
test('should handle undefined values in row', () => {
const columnRow = {
label: undefined,
column_name: 'test_column',
};
const element = document.body;
render(<LeftCell row={columnRow} rowType="column" />);
expect(element).toBeTruthy();
});
test('should properly template URL with metric context', () => {
const metricRow = {
metric_name: 'AVG(price)',
label: 'Average Price',
id: 123,
};
const url = 'http://example.com/{{metric.metric_name}}/{{metric.id}}';
render(<LeftCell row={metricRow} rowType="metric" url={url} />);
const { container } = render(
<LeftCell row={metricRow} rowType="metric" url={url} />,
);
expect(container.firstChild).toBeInTheDocument();
});
test('should handle complex templating patterns', () => {
const columnRow = {
label: 'Sales Data',
column_name: 'sales',
type: 'numeric',
};
const complexUrl =
'http://example.com/{{metric.column_name}}?type={{metric.type}}&label={{metric.label}}';
render(<LeftCell row={columnRow} rowType="column" url={complexUrl} />);
const link = screen.getByRole('link');
expect(link).toHaveAttribute(
'href',
'http://example.com/sales?type=numeric&label=Sales Data',
);
});
});

View File

@@ -0,0 +1,67 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactElement, useMemo } from 'react';
import Mustache from 'mustache';
import { Typography } from '@superset-ui/core/components';
import { MetricOption } from '@superset-ui/chart-controls';
import type { Row, ColumnRow, MetricRow } from '../../types';
interface LeftCellProps {
row: Row;
rowType: 'column' | 'metric';
url?: string;
}
/**
* Renders the left cell containing either column labels or metric information
*/
const LeftCell = ({ row, rowType, url }: LeftCellProps): ReactElement => {
const fullUrl = useMemo(() => {
if (!url) return undefined;
const context = { metric: row };
return Mustache.render(url, context);
}, [url, row]);
if (rowType === 'column') {
const column = row as ColumnRow;
if (fullUrl)
return (
<Typography.Link
href={fullUrl}
rel="noopener noreferrer"
target="_blank"
>
{column.label}
</Typography.Link>
);
return <span>{column.label || ''}</span>;
}
return (
<MetricOption
metric={row as MetricRow}
url={fullUrl}
showFormula={false}
openInNewWindow
/>
);
};
export default LeftCell;

View File

@@ -16,4 +16,5 @@
* specific language governing permissions and limitations
* under the License.
*/
declare module '@data-ui/sparkline';
export { default } from './LeftCell';

View File

@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
declare module 'mustache' {
interface MustacheStatic {
render(template: string, view: any, partials?: any, config?: any): string;
}
const Mustache: MustacheStatic;
export = Mustache;
}

View File

@@ -0,0 +1,118 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render } from '@superset-ui/core/spec';
import Sparkline from './Sparkline';
const mockEntries = [
{ time: '2023-01-01', sales: 100 },
{ time: '2023-01-02', sales: 200 },
{ time: '2023-01-03', sales: 300 },
{ time: '2023-01-04', sales: 400 },
];
describe('Sparkline', () => {
test('should render basic sparkline without time ratio', () => {
const column = {
key: 'test-sparkline',
colType: 'spark',
width: '200',
height: '40',
};
const { container } = render(
<Sparkline valueField="sales" column={column} entries={mockEntries} />,
);
expect(container.firstChild).toBeInTheDocument();
});
test('should handle time ratio sparkline', () => {
const column = {
key: 'test-sparkline',
colType: 'spark',
timeRatio: 2,
width: '200',
height: '40',
};
const { container } = render(
<Sparkline valueField="sales" column={column} entries={mockEntries} />,
);
expect(container.firstChild).toBeInTheDocument();
});
test('should handle string time ratio', () => {
const column = {
key: 'test-sparkline',
colType: 'spark',
timeRatio: '1',
width: '200',
height: '40',
};
const { container } = render(
<Sparkline valueField="sales" column={column} entries={mockEntries} />,
);
expect(container.firstChild).toBeInTheDocument();
});
test('should use default dimensions when not specified', () => {
const column = {
key: 'test-sparkline',
colType: 'spark',
};
const { container } = render(
<Sparkline valueField="sales" column={column} entries={mockEntries} />,
);
expect(container.firstChild).toBeInTheDocument();
});
test('should handle yAxis bounds configuration', () => {
const column = {
key: 'test-sparkline',
colType: 'spark',
yAxisBounds: [0, 500] as [number, number],
showYAxis: true,
};
const { container } = render(
<Sparkline valueField="sales" column={column} entries={mockEntries} />,
);
expect(container.firstChild).toBeInTheDocument();
});
test('should handle invalid yAxis bounds', () => {
const column = {
key: 'test-sparkline',
colType: 'spark',
yAxisBounds: [] as null[],
};
const { container } = render(
<Sparkline valueField="sales" column={column} entries={mockEntries} />,
);
expect(container.firstChild).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,62 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactElement } from 'react';
import { SparklineCell } from '..';
import {
transformSparklineData,
parseSparklineDimensions,
validateYAxisBounds,
} from '../../utils';
import type { ColumnConfig, Entry } from '../../types';
interface SparklineProps {
valueField: string;
column: ColumnConfig;
entries: Entry[];
}
/**
* Renders a sparkline component with processed data
*/
const Sparkline = ({
valueField,
column,
entries,
}: SparklineProps): ReactElement => {
const sparkData = transformSparklineData(valueField, column, entries);
const { width, height } = parseSparklineDimensions(column);
const yAxisBounds = validateYAxisBounds(column.yAxisBounds);
return (
<SparklineCell
ariaLabel={`spark-${valueField}`}
width={width}
height={height}
data={sparkData}
dataKey={`spark-${valueField}`}
dateFormat={column.dateFormat || ''}
numberFormat={column.d3format || ''}
yAxisBounds={yAxisBounds}
showYAxis={column.showYAxis || false}
entries={entries}
/>
);
};
export default Sparkline;

View File

@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { default } from './Sparkline';

View File

@@ -0,0 +1,145 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render } from '@superset-ui/core/spec';
import SparklineCell from './SparklineCell';
const mockData = [3516979.54, 4724162.6, 1791486.71];
const mockEntries = [
{ time: '2003-01-01 00:00:00', 'SUM(sales)': 3516979.54 },
{ time: '2004-01-01 00:00:00', 'SUM(sales)': 4724162.6 },
{ time: '2005-01-01 00:00:00', 'SUM(sales)': 1791486.71 },
];
const defaultProps = {
ariaLabel: 'spark-test',
dataKey: 'spark-test-key',
data: mockData,
entries: mockEntries,
height: 50,
width: 300,
numberFormat: '',
dateFormat: '',
showYAxis: false,
yAxisBounds: [undefined, undefined] as [
number | undefined,
number | undefined,
],
};
test('should render SparklineCell', () => {
const { container } = render(<SparklineCell {...defaultProps} />);
expect(container).toBeInTheDocument();
});
test('should render svg chart', () => {
render(<SparklineCell {...defaultProps} />);
const svg = document.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute('aria-label', 'spark-test');
});
test('should handle width and height props', () => {
render(<SparklineCell {...defaultProps} width={400} height={100} />);
const svg = document.querySelector('svg');
expect(svg).toHaveAttribute('width', '400');
expect(svg).toHaveAttribute('height', '100');
});
test('should render with y-axis when showYAxis is true', () => {
render(<SparklineCell {...defaultProps} showYAxis />);
const svg = document.querySelector('svg');
const textElements = svg?.querySelectorAll('text');
expect(svg).toBeInTheDocument();
expect(textElements).toBeDefined();
expect(textElements!.length).toBeGreaterThan(0);
});
test('should handle empty data gracefully', () => {
const { container } = render(
<SparklineCell {...defaultProps} data={[]} entries={[]} />,
);
expect(container).toBeInTheDocument();
});
test('should apply custom number format', () => {
const { container } = render(
<SparklineCell {...defaultProps} numberFormat=".2f" showYAxis />,
);
expect(container).toBeInTheDocument();
});
test('should handle y-axis bounds', () => {
const { container } = render(
<SparklineCell
{...defaultProps}
yAxisBounds={[1000000, 5000000]}
showYAxis
/>,
);
expect(container).toBeInTheDocument();
});
test('should render line series', () => {
render(<SparklineCell {...defaultProps} />);
const svg = document.querySelector('svg');
expect(svg).toBeInTheDocument();
});
test('should handle null values in data gracefully', () => {
const dataWithNulls = [3516979.54, null, 1791486.71];
const entriesWithNulls = [
{ time: '2003-01-01 00:00:00', 'SUM(sales)': 3516979.54 },
{ time: '2004-01-01 00:00:00', 'SUM(sales)': null },
{ time: '2005-01-01 00:00:00', 'SUM(sales)': 1791486.71 },
];
const { container } = render(
<SparklineCell
{...defaultProps}
data={dataWithNulls}
entries={entriesWithNulls}
/>,
);
expect(container).toBeInTheDocument();
});
test('should return empty div when all data is null', () => {
const nullData = [null, null, null];
const nullEntries = [
{ time: '2003-01-01 00:00:00', 'SUM(sales)': null },
{ time: '2004-01-01 00:00:00', 'SUM(sales)': null },
{ time: '2005-01-01 00:00:00', 'SUM(sales)': null },
];
const { container } = render(
<SparklineCell {...defaultProps} data={nullData} entries={nullEntries} />,
);
expect(container).toBeInTheDocument();
expect(container.querySelector('svg')).toBeNull();
});

View File

@@ -0,0 +1,240 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactElement, useMemo } from 'react';
import { formatNumber, formatTime, useTheme } from '@superset-ui/core';
import { GridRows } from '@visx/grid';
import { scaleLinear } from '@visx/scale';
import {
Axis,
LineSeries,
Tooltip,
XYChart,
buildChartTheme,
} from '@visx/xychart';
import { extendedDayjs } from '@superset-ui/core/utils/dates';
import {
getSparklineTextWidth,
createYScaleConfig,
transformChartData,
} from '../../utils';
interface Entry {
time: string;
[key: string]: any;
}
interface SparklineCellProps {
ariaLabel: string;
dataKey: string;
className?: string;
data: Array<number | null>;
entries: Entry[];
height?: number;
numberFormat?: string;
dateFormat?: string;
showYAxis?: boolean;
width?: number;
yAxisBounds?: [number | undefined, number | undefined];
}
const MARGIN = {
top: 8,
right: 8,
bottom: 8,
left: 8,
} as const;
const SparklineCell = ({
ariaLabel,
dataKey,
data,
width = 300,
height = 50,
numberFormat = '',
dateFormat = '',
yAxisBounds = [undefined, undefined],
showYAxis = false,
entries = [],
}: SparklineCellProps): ReactElement => {
const theme = useTheme();
const xyTheme = useMemo(
() =>
buildChartTheme({
backgroundColor: `${theme.colorBgContainer}`,
colors: [`${theme.colorText}`],
gridColor: `${theme.colorSplit}`,
gridColorDark: `${theme.colorBorder}`,
tickLength: 6,
}),
[theme],
);
const validData = useMemo(
() => data.filter((value): value is number => value !== null),
[data],
);
const chartData = useMemo(() => transformChartData(data), [data]);
const { yScaleConfig, min, max } = useMemo(
() => createYScaleConfig(validData, yAxisBounds),
[validData, yAxisBounds],
);
const { margin } = useMemo(() => {
if (!showYAxis)
return {
margin: MARGIN,
minLabel: '',
maxLabel: '',
};
const minLbl = formatNumber(numberFormat, min);
const maxLbl = formatNumber(numberFormat, max);
const labelLength = Math.max(
getSparklineTextWidth(minLbl),
getSparklineTextWidth(maxLbl),
);
return {
margin: {
...MARGIN,
right: MARGIN.right + labelLength,
},
minLabel: minLbl,
maxLabel: maxLbl,
};
}, [showYAxis, numberFormat, min, max]);
const innerWidth = width - margin.left - margin.right;
const xAccessor = (d: { x: number; y: number }) => d.x;
const yAccessor = (d: { x: number; y: number }) => d.y;
if (validData.length === 0) return <div style={{ width, height }} />;
return (
<>
<XYChart
accessibilityLabel={ariaLabel}
width={width}
height={height}
margin={margin}
yScale={{
...yScaleConfig,
}}
xScale={{ type: 'band', paddingInner: 0.5 }}
theme={xyTheme}
>
{showYAxis && (
<>
<Axis
hideAxisLine
hideTicks
numTicks={2}
orientation="right"
tickFormat={(value: number) => formatNumber(numberFormat, value)}
tickValues={[min, max]}
/>
<GridRows
left={margin.left}
scale={scaleLinear({
range: [height - margin.top, margin.bottom],
domain: [min, max],
})}
width={innerWidth}
strokeDasharray="3 3"
stroke={theme.colorSplit}
tickValues={[min, max]}
/>
</>
)}
<LineSeries
data={chartData}
dataKey={dataKey}
xAccessor={xAccessor}
yAccessor={yAccessor}
/>
<Tooltip
glyphStyle={{ strokeWidth: 1 }}
showDatumGlyph
showVerticalCrosshair
snapTooltipToDatumX
snapTooltipToDatumY
verticalCrosshairStyle={{
stroke: theme.colorText,
strokeDasharray: '3 3',
strokeWidth: 1,
}}
renderTooltip={({ tooltipData }) => {
const idx = tooltipData?.datumByKey[dataKey]?.index;
if (idx === undefined || !entries[idx]) {
return null;
}
const value = data[idx] ?? 0;
const timeValue = entries[idx]?.time;
return (
<div
css={() => ({
color: theme.colorText,
padding: '8px',
})}
>
<strong
css={() => ({
color: theme.colorText,
display: 'block',
marginBottom: '4px',
})}
>
{formatNumber(numberFormat, value)}
</strong>
{timeValue && (
<div
css={() => ({
color: theme.colorTextSecondary,
fontSize: '12px',
})}
>
{formatTime(
dateFormat,
extendedDayjs.utc(timeValue).toDate(),
)}
</div>
)}
</div>
);
}}
/>
</XYChart>
<style>
{`
svg:not(:root) {
overflow: visible;
}
`}
</style>
</>
);
};
export default SparklineCell;

View File

@@ -0,0 +1,19 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { default } from './SparklineCell';

View File

@@ -0,0 +1,219 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from '@superset-ui/core/spec';
import ValueCell from './ValueCell';
const mockColumn = {
key: 'test-column',
label: 'Test Column',
d3format: '.2f',
};
const mockEntries = [
{ time: '2023-01-03', sales: 300, price: 30 },
{ time: '2023-01-02', sales: 200, price: 20 },
{ time: '2023-01-01', sales: 100, price: 10 },
];
describe('ValueCell', () => {
test('should render simple value without special column type', () => {
render(
<ValueCell
valueField="sales"
column={mockColumn}
reversedEntries={mockEntries}
/>,
);
expect(screen.getByText('300.00')).toBeInTheDocument();
});
test('should handle time column type with diff comparison', () => {
const timeColumn = {
...mockColumn,
colType: 'time',
comparisonType: 'diff',
timeLag: 1,
};
render(
<ValueCell
valueField="sales"
column={timeColumn}
reversedEntries={mockEntries}
/>,
);
expect(screen.getByText('100.00')).toBeInTheDocument();
});
test('should handle time column type with percentage comparison', () => {
const timeColumn = {
...mockColumn,
colType: 'time',
comparisonType: 'perc',
timeLag: 1,
};
render(
<ValueCell
valueField="sales"
column={timeColumn}
reversedEntries={mockEntries}
/>,
);
expect(screen.getByText('1.50')).toBeInTheDocument();
});
test('should handle time column type with percentage change', () => {
const timeColumn = {
...mockColumn,
colType: 'time',
comparisonType: 'perc_change',
timeLag: 1,
};
render(
<ValueCell
valueField="sales"
column={timeColumn}
reversedEntries={mockEntries}
/>,
);
expect(screen.getByText('0.50')).toBeInTheDocument();
});
test('should handle contrib column type', () => {
const contribColumn = {
...mockColumn,
colType: 'contrib',
};
render(
<ValueCell
valueField="sales"
column={contribColumn}
reversedEntries={mockEntries}
/>,
);
expect(screen.getByText('0.91')).toBeInTheDocument();
});
test('should handle avg column type', () => {
const avgColumn = {
...mockColumn,
colType: 'avg',
timeLag: 2,
};
render(
<ValueCell
valueField="sales"
column={avgColumn}
reversedEntries={mockEntries}
/>,
);
expect(screen.getByText('250.00')).toBeInTheDocument();
});
test('should show error message for excessive time lag', () => {
const timeColumn = {
...mockColumn,
colType: 'time',
timeLag: 10,
};
render(
<ValueCell
valueField="sales"
column={timeColumn}
reversedEntries={mockEntries}
/>,
);
expect(
screen.getByText(/The time lag set at 10 is too large/),
).toBeInTheDocument();
});
test('should handle negative time lag', () => {
const timeColumn = {
...mockColumn,
colType: 'time',
comparisonType: 'diff',
timeLag: -1,
};
render(
<ValueCell
valueField="sales"
column={timeColumn}
reversedEntries={mockEntries}
/>,
);
expect(screen.getByText('200.00')).toBeInTheDocument();
});
test('should handle null/undefined values in avg calculation', () => {
const avgColumn = {
...mockColumn,
colType: 'avg',
timeLag: 3,
};
const entriesWithNulls = [
{ time: '2023-01-03', sales: 300 },
{ time: '2023-01-02', sales: null },
{ time: '2023-01-01', sales: 100 },
];
render(
<ValueCell
valueField="sales"
column={avgColumn}
reversedEntries={entriesWithNulls}
/>,
);
expect(screen.getByText('200.00')).toBeInTheDocument();
});
test('should apply color styling when bounds are provided', () => {
const columnWithBounds = {
...mockColumn,
bounds: [0, 1000] as [number, number],
};
const { container } = render(
<ValueCell
valueField="sales"
column={columnWithBounds}
reversedEntries={mockEntries}
/>,
);
const span = container.querySelector('span[data-value="300"]');
expect(span).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactElement, useMemo } from 'react';
import { colorFromBounds, calculateCellValue } from '../../utils';
import FormattedNumber from '../FormattedNumber';
import type { ColumnConfig, Entry } from '../../types';
interface ValueCellProps {
valueField: string;
column: ColumnConfig;
reversedEntries: Entry[];
}
/**
* Renders a value cell with different calculation types (time, contrib, avg)
* and applies color coding based on bounds
*/
const ValueCell = ({
valueField,
column,
reversedEntries,
}: ValueCellProps): ReactElement => {
const { value: v, errorMsg } = useMemo(
() => calculateCellValue(valueField, column, reversedEntries),
[valueField, column, reversedEntries],
);
const color = colorFromBounds(v, column.bounds);
return (
<span
key={column.key}
data-value={v}
css={theme =>
color && {
boxShadow: `inset 0px -2.5px 0px 0px ${color}`,
borderRight: `2px solid ${theme.colorBorderSecondary}`,
}
}
>
{errorMsg || (
<span style={{ color: color || undefined }}>
<FormattedNumber num={v} format={column.d3format} />
</span>
)}
</span>
);
};
export default ValueCell;

View File

@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { default } from './ValueCell';

View File

@@ -0,0 +1,23 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { default as SparklineCell } from './SparklineCell';
export { default as FormattedNumber } from './FormattedNumber';
export { default as ValueCell } from './ValueCell';
export { default as LeftCell } from './LeftCell';
export { default as Sparkline } from './Sparkline';

View File

@@ -0,0 +1,93 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { controlPanel as controlPanelConfig } from './controlPanel';
describe('TimeTable Control Panel', () => {
test('should have required control panel structure', () => {
expect(controlPanelConfig).toBeDefined();
expect(controlPanelConfig.controlPanelSections).toBeDefined();
expect(Array.isArray(controlPanelConfig.controlPanelSections)).toBe(true);
});
test('should have time series time section', () => {
const sections = controlPanelConfig.controlPanelSections;
expect(sections.length).toBeGreaterThan(0);
expect(sections[0]).toBeDefined();
});
test('should have query section with required controls', () => {
const querySection = controlPanelConfig.controlPanelSections[1];
const { controlSetRows } = querySection!;
expect(querySection).toBeDefined();
expect(querySection!.label).toBe('Query');
expect(querySection!.expanded).toBe(true);
expect(querySection!.controlSetRows).toBeDefined();
expect(controlSetRows).toContainEqual(['metrics']);
expect(controlSetRows).toContainEqual(['adhoc_filters']);
expect(controlSetRows).toContainEqual(['groupby']);
expect(controlSetRows).toContainEqual(['limit']);
expect(controlSetRows).toContainEqual(['row_limit']);
});
test('should have column collection control', () => {
const querySection = controlPanelConfig.controlPanelSections[1];
const columnCollectionRow = querySection!.controlSetRows.find(
(row: any) =>
Array.isArray(row) &&
row.length === 1 &&
row[0].name === 'column_collection',
);
expect(columnCollectionRow).toBeDefined();
expect((columnCollectionRow as any)![0].config.type).toBe(
'CollectionControl',
);
expect((columnCollectionRow as any)![0].config.label).toBe(
'Time series columns',
);
expect((columnCollectionRow as any)![0].config.controlName).toBe(
'TimeSeriesColumnControl',
);
});
test('should have URL control', () => {
const querySection = controlPanelConfig.controlPanelSections[1];
const urlRow = querySection!.controlSetRows.find(
(row: any) =>
Array.isArray(row) && row.length === 1 && row[0].name === 'url',
);
expect(urlRow).toBeDefined();
expect((urlRow as any)![0].config.type).toBe('TextControl');
expect((urlRow as any)![0].config.label).toBe('URL');
});
test('should have control overrides for groupby', () => {
expect(controlPanelConfig.controlOverrides).toBeDefined();
expect(controlPanelConfig.controlOverrides!.groupby).toBeDefined();
expect(controlPanelConfig.controlOverrides!.groupby!.multiple).toBe(false);
});
test('should have form data overrides function', () => {
expect(controlPanelConfig.formDataOverrides).toBeDefined();
expect(typeof controlPanelConfig.formDataOverrides).toBe('function');
});
});

View File

@@ -17,9 +17,13 @@
* under the License.
*/
import { t, validateNonEmpty } from '@superset-ui/core';
import { getStandardizedControls, sections } from '@superset-ui/chart-controls';
import {
ControlPanelConfig,
getStandardizedControls,
sections,
} from '@superset-ui/chart-controls';
export default {
export const controlPanel: ControlPanelConfig = {
controlPanelSections: [
sections.legacyTimeseriesTime,
{

View File

@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { controlPanel } from './controlPanel';

View File

@@ -0,0 +1,21 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { controlPanel } from './controlPanel';
export { transformProps, TableChartProps } from './transformProps';

View File

@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { transformProps, TableChartProps } from './transformProps';

View File

@@ -0,0 +1,284 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
DatasourceType,
ChartProps,
Behavior,
supersetTheme,
Metric,
} from '@superset-ui/core';
import { transformProps, TableChartProps } from './transformProps';
interface ExtendedMetric extends Omit<Metric, 'uuid'> {
uuid?: string;
label?: string;
}
const createMockDatasource = () => {
const metrics: ExtendedMetric[] = [
{
uuid: '1',
metric_name: 'SUM(sales)',
verbose_name: 'Sum of Sales',
label: 'Sum of Sales',
expression: 'SUM(sales)',
warning_text: null,
description: null,
d3format: null,
is_certified: false,
certified_by: null,
certification_details: null,
},
{
uuid: '2',
metric_name: 'AVG(price)',
verbose_name: 'Average Price',
label: 'Average Price', // Additional field for transformProps
expression: 'AVG(price)',
warning_text: null,
description: null,
d3format: null,
is_certified: false,
certified_by: null,
certification_details: null,
},
];
return {
id: 1,
name: 'test_datasource',
type: DatasourceType.Table,
columns: [],
metrics: metrics.map(metric => ({
...metric,
uuid: metric.uuid!,
})) as Metric[],
verboseMap: {},
columnFormats: {},
currencyFormats: {},
};
};
function createMockChartProps(
overrides: Partial<TableChartProps> = {},
): TableChartProps {
const mockDatasource = createMockDatasource();
const defaultFormData = {
columnCollection: [],
groupby: [],
metrics: [{ label: 'SUM(sales)', metric_name: 'SUM(sales)' }] as object[],
url: 'http://example.com',
};
const defaultQueryData = [
{
data: {
records: [
{ time: '2023-01-01', 'SUM(sales)': 1000 },
{ time: '2023-01-02', 'SUM(sales)': 2000 },
],
columns: ['time', 'SUM(sales)'],
},
},
];
const baseChartProps = new ChartProps({
annotationData: {},
datasource: mockDatasource,
initialValues: {},
formData: defaultFormData,
height: 400,
hooks: {},
ownState: {},
filterState: {},
queriesData: defaultQueryData,
width: 800,
behaviors: [] as Behavior[],
theme: supersetTheme,
});
const tableChartProps: TableChartProps = {
...baseChartProps,
formData: {
...defaultFormData,
...overrides.formData,
},
queriesData: overrides.queriesData || defaultQueryData,
datasource: overrides.datasource || mockDatasource,
};
return tableChartProps;
}
describe('TimeTable transformProps', () => {
test('should transform props correctly for metric rows', () => {
const props = createMockChartProps();
const result = transformProps(props);
expect(result).toMatchObject({
height: 400,
data: [
{ time: '2023-01-01', 'SUM(sales)': 1000 },
{ time: '2023-01-02', 'SUM(sales)': 2000 },
],
columnConfigs: [],
rowType: 'metric',
url: 'http://example.com',
});
expect(result.rows).toBeDefined();
expect(result.rows.length).toBe(1);
expect(result.rows[0]).toMatchObject({
metric_name: 'SUM(sales)',
label: 'SUM(sales)',
});
});
test('should transform props correctly for column rows (groupby)', () => {
const props = createMockChartProps({
formData: {
columnCollection: [],
groupby: ['category'],
metrics: [] as object[],
url: 'http://example.com',
},
queriesData: [
{
data: {
records: [
{ time: '2023-01-01', category: 'A', value: 100 },
{ time: '2023-01-02', category: 'B', value: 200 },
],
columns: [
{ column_name: 'category', id: 1 },
{ column_name: 'value', id: 2 },
],
},
},
],
});
const result = transformProps(props);
expect(result.rowType).toBe('column');
expect(result.rows).toBeDefined();
expect(result.rows.length).toBe(2);
});
test('should handle string columns correctly', () => {
const props = createMockChartProps({
formData: {
columnCollection: [],
groupby: ['category'],
metrics: [
{ label: 'SUM(sales)', metric_name: 'SUM(sales)' },
] as object[],
url: 'http://example.com',
},
queriesData: [
{
data: {
records: [],
columns: ['category', 'value'], // string columns
},
},
],
});
const result = transformProps(props);
expect(result.rows).toBeDefined();
expect(result.rows.length).toBe(2);
expect(result.rows[0]).toEqual({ label: 'category' });
expect(result.rows[1]).toEqual({ label: 'value' });
});
test('should handle column collection with time lag conversion', () => {
const props = createMockChartProps({
formData: {
columnCollection: [
{
key: 'test1',
timeLag: '5',
},
{
key: 'test2',
timeLag: 10,
},
{
key: 'test3',
timeLag: '',
},
],
groupby: [],
metrics: [
{ label: 'SUM(sales)', metric_name: 'SUM(sales)' },
] as object[],
url: 'http://example.com',
},
});
const result = transformProps(props);
expect(result.columnConfigs).toBeDefined();
expect(result.columnConfigs.length).toBe(3);
expect(result.columnConfigs[0]).toHaveProperty('timeLag', 5);
expect(result.columnConfigs[1]).toHaveProperty('timeLag', 10);
expect(result.columnConfigs[2]).toHaveProperty('timeLag', '');
});
test('should handle empty metrics array', () => {
const props = createMockChartProps({
formData: {
columnCollection: [],
groupby: [],
metrics: [] as object[],
url: 'http://example.com',
},
});
const result = transformProps(props);
expect(result.rows).toBeDefined();
expect(result.rows.length).toBe(0);
});
test('should handle missing metrics in datasource', () => {
const props = createMockChartProps({
formData: {
columnCollection: [],
groupby: [],
metrics: [
{ label: 'NONEXISTENT_METRIC', metric_name: 'NONEXISTENT_METRIC' },
] as object[],
url: 'http://example.com',
},
});
const result = transformProps(props);
expect(result.rows).toBeDefined();
expect(result.rows.length).toBe(1);
expect(result.rows[0]).toMatchObject({
label: 'NONEXISTENT_METRIC',
metric_name: 'NONEXISTENT_METRIC',
});
});
});

View File

@@ -28,7 +28,7 @@ interface FormData {
interface QueryData {
data: {
records: DataRecord[];
columns: string[];
columns: string[] | Array<{ column_name: string; id: number }>;
};
}
@@ -40,22 +40,20 @@ export type TableChartProps = ChartProps & {
interface ColumnData {
timeLag?: string | number;
}
export default function transformProps(chartProps: TableChartProps) {
export function transformProps(chartProps: TableChartProps) {
const { height, datasource, formData, queriesData } = chartProps;
const { columnCollection = [], groupby, metrics, url } = formData;
const { records, columns } = queriesData[0].data;
const isGroupBy = groupby?.length > 0;
// When there is a "group by",
// each row in the table is a database column
// Otherwise each row in the table is a metric
let rows;
if (isGroupBy) {
rows = columns.map(column =>
typeof column === 'object' ? column : { label: column },
);
} else {
/* eslint-disable */
const metricMap = datasource.metrics.reduce<Record<string, Metric>>(
(acc, current) => {
const map = acc;
@@ -64,18 +62,17 @@ export default function transformProps(chartProps: TableChartProps) {
},
{},
);
/* eslint-disable */
rows = metrics.map(metric =>
typeof metric === 'object' ? metric : metricMap[metric],
);
}
// TODO: Better parse this from controls instead of mutative value here.
columnCollection.forEach(column => {
const c: ColumnData = column;
if (typeof c.timeLag === 'string' && c.timeLag) {
if (typeof c.timeLag === 'string' && c.timeLag)
c.timeLag = parseInt(c.timeLag, 10);
}
});
return {

View File

@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const ACCESSIBLE_COLOR_BOUNDS = ['#ca0020', '#0571b0'];

View File

@@ -17,10 +17,9 @@
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import transformProps from './transformProps';
import { transformProps, controlPanel } from './config';
import thumbnail from './images/thumbnail.png';
import example from './images/example.jpg';
import controlPanel from './controlPanel';
const metadata = new ChartMetadata({
category: t('Table'),

View File

@@ -0,0 +1,83 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export interface ColumnConfig {
key: string;
label?: string;
d3format?: string;
colType?: string;
comparisonType?: string;
bounds?: [number | null, number | null] | null[];
timeRatio?: string | number;
timeLag?: number;
tooltip?: string;
width?: string;
height?: string;
dateFormat?: string;
yAxisBounds?: [number | undefined, number | undefined] | null[];
showYAxis?: boolean;
}
export interface ColumnRow {
label?: string;
column_name?: string;
[key: string]: any;
}
export interface MetricRow {
label?: string;
metric_name: string;
verbose_name?: string;
expression?: string;
warning_text?: string;
description?: string;
d3format?: string;
is_certified?: boolean;
certified_by?: string;
certification_details?: string;
[key: string]: any;
}
export type Row = ColumnRow | MetricRow;
export interface TimeTableData {
[timestamp: string]: {
[metric: string]: number;
};
}
export interface TimeTableProps {
className?: string;
height?: number;
data: TimeTableData;
columnConfigs: ColumnConfig[];
rowType: 'column' | 'metric';
rows: Row[];
url?: string;
}
export interface Entry {
time: string;
[metric: string]: any;
}
export interface Stats {
count: number;
sum: number;
}

View File

@@ -0,0 +1,93 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { colorFromBounds } from './colorUtils';
describe('colorFromBounds', () => {
test('should return null when no bounds are provided', () => {
expect(colorFromBounds(50)).toBeNull();
expect(colorFromBounds(50, undefined)).toBeNull();
});
test('should return null when bounds is empty array', () => {
expect(colorFromBounds(50, [])).toBeNull();
});
test('should handle min and max bounds with color scale', () => {
const result = colorFromBounds(50, [0, 100]);
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
});
test('should handle only min bound', () => {
const result1 = colorFromBounds(50, [30, null]);
const result2 = colorFromBounds(20, [30, null]);
expect(result1).toBe('#0571b0'); // maxColor when value >= min
expect(result2).toBe('#ca0020'); // minColor when value < min
});
test('should handle only max bound', () => {
const result1 = colorFromBounds(50, [null, 70]);
const result2 = colorFromBounds(80, [null, 70]);
expect(result1).toBe('#0571b0'); // maxColor when value < max
expect(result2).toBe('#ca0020'); // minColor when value >= max
});
test('should handle null value with min bound', () => {
const result = colorFromBounds(null, [30, null]);
expect(result).toBe('#ca0020'); // minColor when value is null
});
test('should handle null value with max bound', () => {
const result = colorFromBounds(null, [null, 70]);
expect(result).toBe('#ca0020'); // minColor when value is null
});
test('should handle custom color bounds', () => {
const customColors = ['#ff0000', '#00ff00'];
const result1 = colorFromBounds(50, [30, null], customColors);
const result2 = colorFromBounds(20, [30, null], customColors);
expect(result1).toBe('#00ff00'); // custom maxColor
expect(result2).toBe('#ff0000'); // custom minColor
});
test('should handle edge case with min equals max', () => {
const result = colorFromBounds(50, [50, 50]);
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
});
test('should handle value of 0', () => {
const result = colorFromBounds(0, [0, 100]);
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
});
test('should handle negative values', () => {
const result = colorFromBounds(-50, [-100, 0]);
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
});
});

View File

@@ -0,0 +1,59 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { scaleLinear } from '@visx/scale';
import { ACCESSIBLE_COLOR_BOUNDS } from '../../constants';
/**
* Generates a color based on a numeric value and color bounds
* @param value - The numeric value to generate color for
* @param bounds - Optional array containing min and max bounds
* @param colorBounds - Array of colors to use for the bounds
* @returns A color string or null if no bounds are provided
*/
export function colorFromBounds(
value: number | null,
bounds?: [number | null, number | null] | null[],
colorBounds: string[] = ACCESSIBLE_COLOR_BOUNDS,
): string | null {
if (bounds && bounds.length > 0) {
const [min, max] = bounds;
const [minColor, maxColor] = colorBounds;
if (
min !== null &&
max !== null &&
min !== undefined &&
max !== undefined
) {
const colorScale = scaleLinear<string>()
.domain([min, (max + min) / 2, max])
.range([minColor, 'grey', maxColor]);
return colorScale(value || 0) || null;
}
if (min !== null && min !== undefined)
return value !== null && value >= min ? maxColor : minColor;
if (max !== null && max !== undefined)
return value !== null && value < max ? maxColor : minColor;
}
return null;
}

View File

@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { colorFromBounds } from './colorUtils';

View File

@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './colorUtils';
export * from './numberUtils';
export * from './rowProcessing';
export * from './sortUtils';
export * from './sparklineDataUtils';
export * from './sparklineHelpers';
export * from './valueCalculations';

View File

@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { parseToNumber } from './numberUtils';

View File

@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { parseToNumber } from './numberUtils';
test('should handle numeric values', () => {
expect(parseToNumber(123)).toBe(123);
expect(parseToNumber(45.67)).toBe(45.67);
expect(parseToNumber(0)).toBe(0);
expect(parseToNumber(-123)).toBe(-123);
});
test('should handle string values', () => {
expect(parseToNumber('123')).toBe(123);
expect(parseToNumber('45.67')).toBe(45.67);
expect(parseToNumber('0')).toBe(0);
expect(parseToNumber('-123')).toBe(-123);
});
test('should handle null and undefined values', () => {
expect(parseToNumber(null)).toBe(0);
expect(parseToNumber(undefined)).toBe(0);
});
test('should handle invalid string values', () => {
expect(parseToNumber('not a number')).toBe(0);
expect(parseToNumber('abc123')).toBe(0);
expect(parseToNumber('')).toBe(0);
});

View File

@@ -0,0 +1,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Safely parses a value to a numeric type
* @param value - The value to parse (string, number, or null)
* @returns The numeric value or 0 if invalid
*/
export function parseToNumber(value?: string | number | null): number {
const displayValue = value ?? 0;
const numericValue =
typeof displayValue === 'string' ? parseFloat(displayValue) : displayValue;
return Number.isNaN(numericValue) ? 0 : numericValue;
}

View File

@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { processTimeTableData } from './rowProcessing';

View File

@@ -0,0 +1,109 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { processTimeTableData } from './rowProcessing';
const mockData = {
'2023-01-01': { sales: 100 },
'2023-01-02': { sales: 200 },
'2023-01-03': { sales: 300 },
};
describe('processTimeTableData', () => {
test('should convert data to sorted entries', () => {
const result = processTimeTableData(mockData);
expect(result.entries).toHaveLength(3);
expect(result.entries[0]).toEqual({ time: '2023-01-01', sales: 100 });
expect(result.entries[1]).toEqual({ time: '2023-01-02', sales: 200 });
expect(result.entries[2]).toEqual({ time: '2023-01-03', sales: 300 });
});
test('should create reversed entries', () => {
const result = processTimeTableData(mockData);
expect(result.reversedEntries).toHaveLength(3);
expect(result.reversedEntries[0]).toEqual({
time: '2023-01-03',
sales: 300,
});
expect(result.reversedEntries[1]).toEqual({
time: '2023-01-02',
sales: 200,
});
expect(result.reversedEntries[2]).toEqual({
time: '2023-01-01',
sales: 100,
});
});
test('should sort data entries by time regardless of input order', () => {
const unsortedData = {
'2023-01-03': { sales: 300 },
'2023-01-01': { sales: 100 },
'2023-01-02': { sales: 200 },
};
const result = processTimeTableData(unsortedData);
expect(result.entries[0].time).toBe('2023-01-01');
expect(result.entries[1].time).toBe('2023-01-02');
expect(result.entries[2].time).toBe('2023-01-03');
});
test('should handle empty data', () => {
const result = processTimeTableData({});
expect(result.entries).toHaveLength(0);
expect(result.reversedEntries).toHaveLength(0);
});
test('should preserve all data fields', () => {
const complexData = {
'2023-01-01': { sales: 100, profit: 50, customers: 25 },
'2023-01-02': { sales: 200, profit: 75, customers: 30 },
};
const result = processTimeTableData(complexData);
expect(result.entries[0]).toEqual({
time: '2023-01-01',
sales: 100,
profit: 50,
customers: 25,
});
expect(result.entries[1]).toEqual({
time: '2023-01-02',
sales: 200,
profit: 75,
customers: 30,
});
});
test('should handle single entry', () => {
const singleData = {
'2023-01-01': { sales: 100 },
};
const result = processTimeTableData(singleData);
expect(result.entries).toHaveLength(1);
expect(result.reversedEntries).toHaveLength(1);
expect(result.entries[0]).toEqual(result.reversedEntries[0]);
});
});

View File

@@ -0,0 +1,35 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { TimeTableData, Entry } from '../../types';
/**
* Converts raw time table data into sorted entries
*/
export function processTimeTableData(data: TimeTableData): {
entries: Entry[];
reversedEntries: Entry[];
} {
const entries: Entry[] = Object.keys(data)
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
.map(time => ({ ...data[time], time }));
const reversedEntries = [...entries].reverse();
return { entries, reversedEntries };
}

View File

@@ -0,0 +1,20 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { sortNumberWithMixedTypes } from './sortUtils';

View File

@@ -0,0 +1,117 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { sortNumberWithMixedTypes } from './sortUtils';
jest.mock('src/utils/sortNumericValues', () => ({
sortNumericValues: jest.fn((a, b, options) => {
const numA = Number(a);
const numB = Number(b);
if (Number.isNaN(numA) && Number.isNaN(numB)) return 0;
if (Number.isNaN(numA))
return options.nanTreatment === 'asSmallest' ? -1 : 1;
if (Number.isNaN(numB))
return options.nanTreatment === 'asSmallest' ? 1 : -1;
return numA - numB;
}),
}));
describe('sortNumberWithMixedTypes', () => {
const createMockRow = (value: any) => ({
values: {
testColumn: {
props: {
'data-value': value,
},
},
},
});
test('should sort numbers in ascending order', () => {
const rowA = createMockRow(10);
const rowB = createMockRow(20);
const result = sortNumberWithMixedTypes(rowA, rowB, 'testColumn', false);
expect(result).toBeLessThan(0); // rowA should come before rowB
});
test('should sort numbers in descending order', () => {
const rowA = createMockRow(10);
const rowB = createMockRow(20);
const result = sortNumberWithMixedTypes(rowA, rowB, 'testColumn', true);
expect(result).toBeGreaterThan(0); // rowB should come before rowA in descending
});
test('should handle equal values', () => {
const rowA = createMockRow(15);
const rowB = createMockRow(15);
const result = sortNumberWithMixedTypes(rowA, rowB, 'testColumn', false);
expect(result).toBe(0);
});
test('should handle null values', () => {
const rowA = createMockRow(null);
const rowB = createMockRow(10);
const result = sortNumberWithMixedTypes(rowA, rowB, 'testColumn', false);
expect(typeof result).toBe('number');
});
test('should handle string numbers', () => {
const rowA = createMockRow('10');
const rowB = createMockRow('20');
const result = sortNumberWithMixedTypes(rowA, rowB, 'testColumn', false);
expect(result).toBeLessThan(0);
});
test('should handle mixed types', () => {
const rowA = createMockRow(10);
const rowB = createMockRow('20');
const result = sortNumberWithMixedTypes(rowA, rowB, 'testColumn', false);
expect(typeof result).toBe('number');
});
test('should handle negative numbers', () => {
const rowA = createMockRow(-10);
const rowB = createMockRow(5);
const result = sortNumberWithMixedTypes(rowA, rowB, 'testColumn', false);
expect(result).toBeLessThan(0);
});
test('should handle zero values', () => {
const rowA = createMockRow(0);
const rowB = createMockRow(10);
const result = sortNumberWithMixedTypes(rowA, rowB, 'testColumn', false);
expect(result).toBeLessThan(0);
});
});

View File

@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Simple numeric value comparison that handles null, undefined, and mixed types
* @param a - First value to compare
* @param b - Second value to compare
* @param nanTreatment - How to treat NaN values
* @returns Numeric comparison result
*/
function compareValues(
a: any,
b: any,
nanTreatment: 'asSmallest' | 'asLargest' | 'alwaysLast' = 'asSmallest',
): number {
const numA = typeof a === 'string' ? parseFloat(a) : a;
const numB = typeof b === 'string' ? parseFloat(b) : b;
const isAValid = numA !== null && numA !== undefined && !Number.isNaN(numA);
const isBValid = numB !== null && numB !== undefined && !Number.isNaN(numB);
if (!isAValid && !isBValid) return 0;
if (!isAValid) return nanTreatment === 'asSmallest' ? -1 : 1;
if (!isBValid) return nanTreatment === 'asSmallest' ? 1 : -1;
return numA - numB;
}
/**
* Sorts table rows with mixed data types for react-table
* @param rowA - First row to compare
* @param rowB - Second row to compare
* @param columnId - Column identifier for sorting
* @param descending - Whether to sort in descending order
* @returns Numeric comparison result for react-table
*/
export function sortNumberWithMixedTypes(
rowA: any,
rowB: any,
columnId: string,
descending: boolean,
) {
const valueA = rowA.values[columnId].props['data-value'];
const valueB = rowB.values[columnId].props['data-value'];
const comparison = compareValues(valueA, valueB, 'asSmallest');
return comparison * (descending ? -1 : 1);
}

View File

@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export {
parseTimeRatio,
transformTimeRatioData,
transformRegularData,
transformSparklineData,
parseSparklineDimensions,
validateYAxisBounds,
} from './sparklineDataUtils';

View File

@@ -0,0 +1,123 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
parseTimeRatio,
transformTimeRatioData,
transformRegularData,
transformSparklineData,
parseSparklineDimensions,
validateYAxisBounds,
} from './sparklineDataUtils';
const mockEntries = [
{ time: '2023-01-01', sales: 100 },
{ time: '2023-01-02', sales: 200 },
{ time: '2023-01-03', sales: 300 },
{ time: '2023-01-04', sales: 400 },
];
describe('sparklineDataUtils', () => {
test('parseTimeRatio should parse string values', () => {
expect(parseTimeRatio('5')).toBe(5);
expect(parseTimeRatio('10')).toBe(10);
});
test('parseTimeRatio should pass through number values', () => {
expect(parseTimeRatio(5)).toBe(5);
expect(parseTimeRatio(10)).toBe(10);
});
test('transformTimeRatioData should calculate ratios correctly', () => {
const result = transformTimeRatioData(mockEntries, 'sales', 1);
expect(result).toHaveLength(3);
expect(result[0]).toBe(2); // 200/100
expect(result[1]).toBe(1.5); // 300/200
expect(result[2]).toBeCloseTo(1.33, 2); // 400/300
});
test('transformTimeRatioData should handle zero values', () => {
const entriesWithZero = [
{ time: '2023-01-01', sales: 0 },
{ time: '2023-01-02', sales: 200 },
];
const result = transformTimeRatioData(entriesWithZero, 'sales', 1);
expect(result).toHaveLength(1);
expect(result[0]).toBeNull();
});
test('transformRegularData should map values directly', () => {
const result = transformRegularData(mockEntries, 'sales');
expect(result).toEqual([100, 200, 300, 400]);
});
test('transformSparklineData should use time ratio when configured', () => {
const column = { key: 'test', timeRatio: 2 };
const result = transformSparklineData('sales', column, mockEntries);
expect(result).toHaveLength(2);
expect(result[0]).toBe(3);
expect(result[1]).toBe(2);
});
test('transformSparklineData should use regular data when no time ratio', () => {
const column = { key: 'test' };
const result = transformSparklineData('sales', column, mockEntries);
expect(result).toEqual([100, 200, 300, 400]);
});
test('transformSparklineData should handle string time ratio', () => {
const column = { key: 'test', timeRatio: '1' };
const result = transformSparklineData('sales', column, mockEntries);
expect(result).toHaveLength(3);
expect(result[0]).toBe(2); // 200/100
});
test('parseSparklineDimensions should use provided values', () => {
const column = { key: 'test', width: '400', height: '100' };
const result = parseSparklineDimensions(column);
expect(result).toEqual({ width: 400, height: 100 });
});
test('parseSparklineDimensions should use default values', () => {
const column = { key: 'test' };
const result = parseSparklineDimensions(column);
expect(result).toEqual({ width: 300, height: 50 });
});
test('validateYAxisBounds should return valid bounds', () => {
const bounds = [0, 100];
const result = validateYAxisBounds(bounds);
expect(result).toEqual([0, 100]);
});
test('validateYAxisBounds should return default for invalid bounds', () => {
expect(validateYAxisBounds([0])).toEqual([undefined, undefined]);
expect(validateYAxisBounds('invalid')).toEqual([undefined, undefined]);
expect(validateYAxisBounds(null)).toEqual([undefined, undefined]);
});
});

View File

@@ -0,0 +1,96 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ColumnConfig, Entry } from '../../types';
/**
* Parses time ratio from string or number
*/
export function parseTimeRatio(timeRatio: string | number): number {
return typeof timeRatio === 'string' ? parseInt(timeRatio, 10) : timeRatio;
}
/**
* Transforms entries into time ratio sparkline data
*/
export function transformTimeRatioData(
entries: Entry[],
valueField: string,
timeRatio: number,
): (number | null)[] {
const sparkData: (number | null)[] = [];
for (let i = timeRatio; i < entries.length; i += 1) {
const prevData = entries[i - timeRatio][valueField];
if (prevData && prevData !== 0) {
sparkData.push(entries[i][valueField] / prevData);
} else {
sparkData.push(null);
}
}
return sparkData;
}
/**
* Transforms entries into regular sparkline data
*/
export function transformRegularData(
entries: Entry[],
valueField: string,
): (number | null)[] {
return entries.map(d => d[valueField]);
}
/**
* Transforms entries into sparkline data based on column configuration
*/
export function transformSparklineData(
valueField: string,
column: ColumnConfig,
entries: Entry[],
): (number | null)[] {
if (column.timeRatio) {
const timeRatio = parseTimeRatio(column.timeRatio);
return transformTimeRatioData(entries, valueField, timeRatio);
}
return transformRegularData(entries, valueField);
}
/**
* Parses dimension values with defaults
*/
export function parseSparklineDimensions(column: ColumnConfig) {
return {
width: parseInt(column.width || '300', 10),
height: parseInt(column.height || '50', 10),
};
}
/**
* Validates and formats y-axis bounds
*/
export function validateYAxisBounds(
yAxisBounds: unknown,
): [number | undefined, number | undefined] {
if (Array.isArray(yAxisBounds) && yAxisBounds.length === 2)
return yAxisBounds as [number | undefined, number | undefined];
return [undefined, undefined];
}

View File

@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export {
getSparklineTextWidth,
isValidBoundValue,
getDataBounds,
createYScaleConfig,
transformChartData,
} from './sparklineHelpers';

View File

@@ -0,0 +1,190 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
getSparklineTextWidth,
isValidBoundValue,
getDataBounds,
createYScaleConfig,
transformChartData,
} from './sparklineHelpers';
describe('sparklineHelpers', () => {
describe('getSparklineTextWidth', () => {
test('should return a positive number for text width', () => {
const result = getSparklineTextWidth('123.45');
expect(result).toBeGreaterThan(0);
expect(typeof result).toBe('number');
});
test('should handle empty string', () => {
const result = getSparklineTextWidth('');
expect(result).toBeGreaterThan(0);
});
});
describe('isValidBoundValue', () => {
test('should return true for valid numbers', () => {
expect(isValidBoundValue(0)).toBe(true);
expect(isValidBoundValue(123)).toBe(true);
expect(isValidBoundValue(-45)).toBe(true);
expect(isValidBoundValue(0.5)).toBe(true);
});
test('should return false for invalid values', () => {
expect(isValidBoundValue(null as any)).toBe(false);
expect(isValidBoundValue(undefined)).toBe(false);
expect(isValidBoundValue('')).toBe(false);
expect(isValidBoundValue(NaN)).toBe(false);
expect(isValidBoundValue('string')).toBe(false);
});
});
describe('getDataBounds', () => {
test('should return correct min and max for valid data', () => {
const data = [10, 5, 20, 15];
const [min, max] = getDataBounds(data);
expect(min).toBe(5);
expect(max).toBe(20);
});
test('should handle single value', () => {
const data = [42];
const [min, max] = getDataBounds(data);
expect(min).toBe(42);
expect(max).toBe(42);
});
test('should return [0, 0] for empty data', () => {
const data: number[] = [];
const [min, max] = getDataBounds(data);
expect(min).toBe(0);
expect(max).toBe(0);
});
test('should handle negative numbers', () => {
const data = [-10, -5, -20];
const [min, max] = getDataBounds(data);
expect(min).toBe(-20);
expect(max).toBe(-5);
});
});
describe('createYScaleConfig', () => {
test('should use data bounds when no axis bounds provided', () => {
const validData = [10, 20, 30];
const result = createYScaleConfig(validData);
expect(result.min).toBe(10);
expect(result.max).toBe(30);
expect(result.yScaleConfig.domain).toEqual([10, 30]);
expect(result.yScaleConfig.zero).toBe(false);
});
test('should use provided bounds when valid', () => {
const validData = [10, 20, 30];
const result = createYScaleConfig(validData, [0, 50]);
expect(result.min).toBe(0);
expect(result.max).toBe(50);
expect(result.yScaleConfig.domain).toEqual([0, 50]);
expect(result.yScaleConfig.zero).toBe(true);
});
test('should mix data and provided bounds', () => {
const validData = [10, 20, 30];
const result = createYScaleConfig(validData, [5, undefined]);
expect(result.min).toBe(5);
expect(result.max).toBe(30); // Uses data max
expect(result.yScaleConfig.domain).toEqual([5, 30]);
});
test('should handle empty data', () => {
const validData: number[] = [];
const result = createYScaleConfig(validData);
expect(result.min).toBe(0);
expect(result.max).toBe(0);
expect(result.yScaleConfig.domain).toEqual([0, 0]);
});
test('should set zero=true for negative min bound', () => {
const validData = [10, 20];
const result = createYScaleConfig(validData, [-5, undefined]);
expect(result.yScaleConfig.zero).toBe(true);
});
test('should set zero=false for positive min bound', () => {
const validData = [10, 20];
const result = createYScaleConfig(validData, [5, undefined]);
expect(result.yScaleConfig.zero).toBe(false);
});
});
describe('transformChartData', () => {
test('should transform data with indices', () => {
const data = [10, 20, 30];
const result = transformChartData(data);
expect(result).toEqual([
{ x: 0, y: 10 },
{ x: 1, y: 20 },
{ x: 2, y: 30 },
]);
});
test('should handle null values', () => {
const data = [10, null, 30];
const result = transformChartData(data);
expect(result).toEqual([
{ x: 0, y: 10 },
{ x: 1, y: 0 },
{ x: 2, y: 30 },
]);
});
test('should handle empty array', () => {
const data: Array<number | null> = [];
const result = transformChartData(data);
expect(result).toEqual([]);
});
test('should handle mixed null and number values', () => {
const data = [null, 10, null, null, 20];
const result = transformChartData(data);
expect(result).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 10 },
{ x: 2, y: 0 },
{ x: 3, y: 0 },
{ x: 4, y: 20 },
]);
});
});
});

View File

@@ -0,0 +1,102 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getTextDimension } from '@superset-ui/core';
import { LinearScaleConfig } from '@visx/scale';
import { AxisScaleOutput } from '@visx/axis';
const TEXT_STYLE = {
fontSize: '12px',
fontWeight: 200,
letterSpacing: 0.4,
} as const;
/**
* Calculates the width needed for text in the sparkline
*/
export function getSparklineTextWidth(text: string): number {
return getTextDimension({ text, style: TEXT_STYLE }).width + 5;
}
/**
* Validates if a value can be used as a bound
*/
export function isValidBoundValue(value?: number | string): value is number {
return (
value !== null &&
value !== undefined &&
value !== '' &&
typeof value === 'number' &&
!Number.isNaN(value)
);
}
/**
* Calculates min and max values from valid data points
*/
export function getDataBounds(validData: number[]): [number, number] {
if (validData.length === 0) {
return [0, 0];
}
const min = Math.min(...validData);
const max = Math.max(...validData);
return [min, max];
}
/**
* Creates Y scale configuration based on data and bounds
*/
export function createYScaleConfig(
validData: number[],
yAxisBounds?: [number | undefined, number | undefined],
): {
yScaleConfig: LinearScaleConfig<AxisScaleOutput>;
min: number;
max: number;
} {
const [dataMin, dataMax] = getDataBounds(validData);
const [minBound, maxBound] = yAxisBounds || [undefined, undefined];
const hasMinBound = isValidBoundValue(minBound);
const hasMaxBound = isValidBoundValue(maxBound);
const finalMin = hasMinBound ? minBound! : dataMin;
const finalMax = hasMaxBound ? maxBound! : dataMax;
const config: LinearScaleConfig<AxisScaleOutput> = {
type: 'linear',
zero: hasMinBound && minBound! <= 0,
domain: [finalMin, finalMax],
};
return {
yScaleConfig: config,
min: finalMin,
max: finalMax,
};
}
/**
* Transforms raw data into chart data points
*/
export function transformChartData(
data: Array<number | null>,
): Array<{ x: number; y: number }> {
return data.map((num, idx) => ({ x: idx, y: num ?? 0 }));
}

View File

@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export {
calculateTimeValue,
calculateContribution,
calculateAverage,
calculateCellValue,
type ValueCalculationResult,
} from './valueCalculations';

View File

@@ -0,0 +1,274 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
calculateTimeValue,
calculateContribution,
calculateAverage,
calculateCellValue,
} from './valueCalculations';
import type { ColumnConfig, Entry } from '../../types';
describe('valueCalculations', () => {
const mockEntries: Entry[] = [
{ time: '2023-01-03', sales: 300, price: 30 },
{ time: '2023-01-02', sales: 200, price: 20 },
{ time: '2023-01-01', sales: 100, price: 10 },
];
describe('calculateTimeValue', () => {
test('should calculate diff comparison correctly', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'time',
comparisonType: 'diff',
timeLag: 1,
};
const result = calculateTimeValue(300, 'sales', mockEntries, column);
expect(result.value).toBe(100);
expect(result.errorMsg).toBeUndefined();
});
test('should calculate perc comparison correctly', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'time',
comparisonType: 'perc',
timeLag: 1,
};
const result = calculateTimeValue(300, 'sales', mockEntries, column);
expect(result.value).toBe(1.5); // 300 / 200
});
test('should calculate perc_change comparison correctly', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'time',
comparisonType: 'perc_change',
timeLag: 1,
};
const result = calculateTimeValue(300, 'sales', mockEntries, column);
expect(result.value).toBe(0.5); // (300 / 200) - 1
});
test('should handle negative time lag', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'time',
comparisonType: 'diff',
timeLag: -1,
};
const result = calculateTimeValue(300, 'sales', mockEntries, column);
expect(result.value).toBe(200); // 300 - 100 (from end of array)
});
test('should return error for excessive time lag', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'time',
timeLag: 10,
};
const result = calculateTimeValue(300, 'sales', mockEntries, column);
expect(result.value).toBeNull();
expect(result.errorMsg).toContain('too large for the length of data');
});
test('should return null for invalid values', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'time',
timeLag: 1,
};
const result = calculateTimeValue(null, 'sales', mockEntries, column);
expect(result.value).toBeNull();
expect(result.errorMsg).toBeUndefined();
});
test('should return lagged value when no comparison type', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'time',
timeLag: 1,
};
const result = calculateTimeValue(300, 'sales', mockEntries, column);
expect(result.value).toBe(200); // lagged value without comparison
});
});
describe('calculateContribution', () => {
test('should calculate contribution correctly', () => {
const result = calculateContribution(300, mockEntries);
expect(result.value).toBeCloseTo(0.909, 3);
expect(result.errorMsg).toBeUndefined();
});
test('should return null for null recent value', () => {
const result = calculateContribution(null, mockEntries);
expect(result.value).toBeNull();
});
test('should return null for empty entries', () => {
const result = calculateContribution(300, []);
expect(result.value).toBeNull();
});
test('should return null when total is zero', () => {
const zeroEntries: Entry[] = [{ time: '2023-01-01', sales: 0 }];
const result = calculateContribution(100, zeroEntries);
expect(result.value).toBeNull();
});
});
describe('calculateAverage', () => {
test('should calculate average correctly', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'avg',
timeLag: 2,
};
const result = calculateAverage('sales', mockEntries, column);
expect(result.value).toBe(250);
expect(result.errorMsg).toBeUndefined();
});
test('should handle null/undefined values', () => {
const entriesWithNulls: Entry[] = [
{ time: '2023-01-03', sales: 300 },
{ time: '2023-01-02', sales: null },
{ time: '2023-01-01', sales: 100 },
];
const column: ColumnConfig = {
key: 'test',
colType: 'avg',
timeLag: 3,
};
const result = calculateAverage('sales', entriesWithNulls, column);
expect(result.value).toBe(200);
});
test('should return null for empty entries', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'avg',
};
const result = calculateAverage('sales', [], column);
expect(result.value).toBeNull();
});
test('should return null when all values are null', () => {
const entriesWithAllNulls: Entry[] = [
{ time: '2023-01-03', sales: null },
{ time: '2023-01-02', sales: undefined },
];
const column: ColumnConfig = {
key: 'test',
colType: 'avg',
timeLag: 2,
};
const result = calculateAverage('sales', entriesWithAllNulls, column);
expect(result.value).toBeNull();
});
});
describe('calculateCellValue', () => {
test('should route to time calculation', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'time',
comparisonType: 'diff',
timeLag: 1,
};
const result = calculateCellValue('sales', column, mockEntries);
expect(result.value).toBe(100); // 300 - 200
});
test('should route to contribution calculation', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'contrib',
};
const result = calculateCellValue('sales', column, mockEntries);
expect(result.value).toBeCloseTo(0.909, 3);
});
test('should route to average calculation', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'avg',
timeLag: 2,
};
const result = calculateCellValue('sales', column, mockEntries);
expect(result.value).toBe(250);
});
test('should return recent value for default case', () => {
const column: ColumnConfig = {
key: 'test',
};
const result = calculateCellValue('sales', column, mockEntries);
expect(result.value).toBe(300); // Recent value
});
test('should return null for empty entries', () => {
const column: ColumnConfig = {
key: 'test',
colType: 'time',
};
const result = calculateCellValue('sales', column, []);
expect(result.value).toBeNull();
});
});
});

View File

@@ -0,0 +1,139 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ColumnConfig, Entry } from '../../types';
export interface ValueCalculationResult {
value: number | null;
errorMsg?: string;
}
/**
* Calculates time-based values with lag and comparison types
*/
export function calculateTimeValue(
recent: number | null,
valueField: string,
reversedEntries: Entry[],
column: ColumnConfig,
): ValueCalculationResult {
const timeLag = column.timeLag || 0;
const totalLag = reversedEntries.length;
if (Math.abs(timeLag) >= totalLag)
return {
value: null,
errorMsg: `The time lag set at ${timeLag} is too large for the length of data at ${reversedEntries.length}. No data available.`,
};
let laggedValue: number | null;
if (timeLag < 0)
laggedValue = reversedEntries[totalLag + timeLag][valueField];
else laggedValue = reversedEntries[timeLag][valueField];
if (typeof laggedValue !== 'number' || typeof recent !== 'number')
return { value: null };
let calculatedValue: number;
if (column.comparisonType === 'diff') calculatedValue = recent - laggedValue;
else if (column.comparisonType === 'perc')
calculatedValue = recent / laggedValue;
else if (column.comparisonType === 'perc_change')
calculatedValue = recent / laggedValue - 1;
else calculatedValue = laggedValue;
return { value: calculatedValue };
}
/**
* Calculates contribution value (percentage of total)
*/
export function calculateContribution(
recent: number | null,
reversedEntries: Entry[],
): ValueCalculationResult {
if (typeof recent !== 'number' || reversedEntries.length === 0)
return { value: null };
const firstEntry = reversedEntries[0];
let total = 0;
Object.keys(firstEntry).forEach(k => {
if (k !== 'time' && typeof firstEntry[k] === 'number')
total += firstEntry[k];
});
if (total === 0) return { value: null };
return { value: recent / total };
}
/**
* Calculates average value over a time period
*/
export function calculateAverage(
valueField: string,
reversedEntries: Entry[],
column: ColumnConfig,
): ValueCalculationResult {
if (reversedEntries.length === 0) return { value: null };
const sliceEnd = column.timeLag || reversedEntries.length;
let count = 0;
let sum = 0;
for (let i = 0; i < sliceEnd && i < reversedEntries.length; i += 1) {
const value = reversedEntries[i][valueField];
if (value !== undefined && value !== null) {
count += 1;
sum += value;
}
}
if (count === 0) return { value: null };
return { value: sum / count };
}
/**
* Calculates cell values based on column type
*/
export function calculateCellValue(
valueField: string,
column: ColumnConfig,
reversedEntries: Entry[],
): ValueCalculationResult {
if (reversedEntries.length === 0) return { value: null };
const recent = reversedEntries[0][valueField];
if (column.colType === 'time')
return calculateTimeValue(recent, valueField, reversedEntries, column);
if (column.colType === 'contrib')
return calculateContribution(recent, reversedEntries);
if (column.colType === 'avg')
return calculateAverage(valueField, reversedEntries, column);
return { value: recent };
}