mirror of
https://github.com/apache/superset.git
synced 2026-04-20 00:24:38 +00:00
fix(theming): fix TimeTable chart issues (#34868)
This commit is contained in:
committed by
GitHub
parent
4695be5cc5
commit
d183969744
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
144
superset-frontend/src/visualizations/TimeTable/TimeTable.tsx
Normal file
144
superset-frontend/src/visualizations/TimeTable/TimeTable.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -16,4 +16,5 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
declare module '@data-ui/sparkline';
|
||||
|
||||
export { default } from './LeftCell';
|
||||
26
superset-frontend/src/visualizations/TimeTable/components/LeftCell/mustache.d.ts
vendored
Normal file
26
superset-frontend/src/visualizations/TimeTable/components/LeftCell/mustache.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
{
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
20
superset-frontend/src/visualizations/TimeTable/constants.ts
Normal file
20
superset-frontend/src/visualizations/TimeTable/constants.ts
Normal 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'];
|
||||
@@ -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'),
|
||||
|
||||
83
superset-frontend/src/visualizations/TimeTable/types.ts
Normal file
83
superset-frontend/src/visualizations/TimeTable/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user