[Dashboards] add No data layer on charts (#16397)

video qa


https://github.com/user-attachments/assets/01c790de-eae7-406e-9308-af036f53ab2e
This commit is contained in:
nitin
2025-12-09 16:17:42 +05:30
committed by GitHub
parent 790a8d6f93
commit 7e8151355d
22 changed files with 661 additions and 158 deletions
@@ -0,0 +1,34 @@
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
type NoDataLayerProps = {
innerWidth: number;
innerHeight: number;
hasNoData: boolean;
};
export const NoDataLayer = ({
innerWidth,
innerHeight,
hasNoData,
}: NoDataLayerProps) => {
const theme = useTheme();
const { t } = useLingui();
if (!hasNoData) {
return null;
}
return (
<text
x={innerWidth / 2}
y={innerHeight / 2}
textAnchor="middle"
dominantBaseline="middle"
fill={theme.font.color.tertiary}
fontSize={theme.font.size.md}
>
{t`No data`}
</text>
);
};
@@ -3,12 +3,14 @@ import { type Meta, type StoryObj } from '@storybook/react';
import { GraphWidgetTestWrapper } from '@/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper';
import { GraphWidgetBarChart } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart';
import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof GraphWidgetBarChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart',
component: GraphWidgetBarChart,
decorators: [
I18nFrontDecorator,
(Story) => (
<GraphWidgetTestWrapper>
<Story />
@@ -3,12 +3,14 @@ import { type ComponentProps } from 'react';
import { GraphWidgetTestWrapper } from '@/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper';
import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof GraphWidgetLineChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetLineChart',
component: GraphWidgetLineChart,
decorators: [
I18nFrontDecorator,
(Story) => (
<GraphWidgetTestWrapper>
<Story />
@@ -0,0 +1,6 @@
export const NICE_STEP_MULTIPLIERS = {
LARGE: 10,
MEDIUM: 5,
SMALL: 2,
DEFAULT: 1,
} as const;
@@ -0,0 +1,5 @@
export const NICE_STEP_NORMALIZED_VALUE_THRESHOLDS = {
LARGE: 5,
MEDIUM: 2,
SMALL: 1,
} as const;
@@ -1,5 +1,6 @@
import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer';
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
import { NoDataLayer } from '@/page-layout/widgets/graph/components/NoDataLayer';
import { CustomBarItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem';
import { CustomTotalsLayer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer';
import { GraphBarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip';
@@ -14,6 +15,9 @@ import { getBarChartAxisConfigs } from '@/page-layout/widgets/graph/graphWidgetB
import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor';
import { getBarChartInnerPadding } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding';
import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins';
import { getBarChartTickConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig';
import { computeEffectiveValueRange } from '@/page-layout/widgets/graph/utils/computeEffectiveValueRange';
import { computeValueTickValues } from '@/page-layout/widgets/graph/utils/computeValueTickValues';
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
import {
formatGraphValue,
@@ -25,6 +29,7 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
ResponsiveBar,
type BarCustomLayerProps,
type BarDatum,
type BarItemProps,
type ComputedBarDatum,
@@ -37,6 +42,8 @@ import { useDebouncedCallback } from 'use-debounce';
import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
type NoDataLayerWrapperProps = BarCustomLayerProps<BarDatum>;
type GraphWidgetBarChartProps = {
data: BarDatum[];
indexBy: string;
@@ -120,6 +127,38 @@ export const GraphWidgetBarChart = ({
seriesLabels,
});
const calculatedValueRange =
groupMode === 'stacked'
? calculateStackedBarChartValueRange(data, keys)
: calculateValueRangeFromBarChartKeys(data, keys);
const { effectiveMinimumValue, effectiveMaximumValue, hasNoData } =
computeEffectiveValueRange({
calculatedMinimum: calculatedValueRange.minimum,
calculatedMaximum: calculatedValueRange.maximum,
rangeMin,
rangeMax,
dataLength: data.length,
});
const tickConfig = getBarChartTickConfig({
width: chartWidth,
height: chartHeight,
data,
indexBy,
xAxisLabel,
yAxisLabel,
axisFontSize: chartTheme.axis.ticks.text.fontSize,
layout,
});
const { tickValues: valueTickValues, domain: valueDomain } =
computeValueTickValues({
minimum: effectiveMinimumValue,
maximum: effectiveMaximumValue,
tickCount: tickConfig.numberOfValueTicks,
});
const hasClickableItems = isDefined(onBarClick);
const hideTooltip = () => setActiveBarTooltip(null);
@@ -157,6 +196,8 @@ export const GraphWidgetBarChart = ({
yAxisLabel,
formatOptions,
axisFontSize: chartTheme.axis.ticks.text.fontSize,
valueTickValues,
tickConfig,
});
const BarItemWithContext = useMemo(
@@ -179,25 +220,32 @@ export const GraphWidgetBarChart = ({
bars,
}: {
bars: readonly ComputedBarDatum<BarDatum>[];
}) => (
<CustomTotalsLayer
bars={bars}
formatValue={(value) => formatGraphValue(value, formatOptions)}
offset={theme.spacingMultiplicator * 2}
layout={layout}
groupMode={groupMode}
omitNullValues={omitNullValues}
showValues={showValues}
}) => {
if (hasNoData) {
return null;
}
return (
<CustomTotalsLayer
bars={bars}
formatValue={(value) => formatGraphValue(value, formatOptions)}
offset={theme.spacingMultiplicator * 2}
layout={layout}
groupMode={groupMode}
omitNullValues={omitNullValues}
showValues={showValues}
/>
);
};
const NoDataLayerWrapper = (layerProps: NoDataLayerWrapperProps) => (
<NoDataLayer
innerWidth={layerProps.innerWidth}
innerHeight={layerProps.innerHeight}
hasNoData={hasNoData}
/>
);
const calculatedValueRange =
groupMode === 'stacked'
? calculateStackedBarChartValueRange(data, keys)
: calculateValueRangeFromBarChartKeys(data, keys);
const effectiveMinimumValue = rangeMin ?? calculatedValueRange.minimum;
const effectiveMaximumValue = rangeMax ?? calculatedValueRange.maximum;
const hasNegativeValues = calculatedValueRange.minimum < 0;
const zeroMarker = hasNegativeValues
? [
@@ -239,13 +287,21 @@ export const GraphWidgetBarChart = ({
layout={layout}
valueScale={{
type: 'linear',
min: effectiveMinimumValue,
max: effectiveMaximumValue,
min: valueDomain.min,
max: valueDomain.max,
clamp: true,
}}
indexScale={{ type: 'band', round: true }}
colors={(datum) => getBarChartColor(datum, barConfigs, theme)}
layers={['grid', 'markers', 'axes', 'bars', 'legends', TotalsLayer]}
layers={[
'grid',
'markers',
'axes',
'bars',
'legends',
TotalsLayer,
NoDataLayerWrapper,
]}
markers={zeroMarker}
axisTop={null}
axisRight={null}
@@ -253,8 +309,12 @@ export const GraphWidgetBarChart = ({
axisLeft={axisLeftConfig}
enableGridX={layout === BarChartLayout.HORIZONTAL && showGrid}
enableGridY={layout === BarChartLayout.VERTICAL && showGrid}
gridXValues={layout === BarChartLayout.HORIZONTAL ? 5 : undefined}
gridYValues={layout === BarChartLayout.VERTICAL ? 5 : undefined}
gridXValues={
layout === BarChartLayout.HORIZONTAL ? valueTickValues : undefined
}
gridYValues={
layout === BarChartLayout.VERTICAL ? valueTickValues : undefined
}
enableLabel={false}
labelSkipWidth={12}
innerPadding={getBarChartInnerPadding({
@@ -275,9 +335,9 @@ export const GraphWidgetBarChart = ({
formatGraphValue(Number(barDatumCandidate.value), formatOptions)
}
tooltip={() => null}
onMouseEnter={handleBarEnter}
onMouseLeave={handleBarLeave}
onClick={onBarClick}
onMouseEnter={hasNoData ? undefined : handleBarEnter}
onMouseLeave={hasNoData ? undefined : handleBarLeave}
onClick={hasNoData ? undefined : onBarClick}
theme={chartTheme}
borderRadius={parseInt(theme.border.radius.sm)}
/>
@@ -294,7 +354,7 @@ export const GraphWidgetBarChart = ({
onMouseLeave={handleTooltipMouseLeave}
/>
<GraphWidgetLegend
show={showLegend}
show={showLegend && !hasNoData}
items={enrichedKeys.map((item) => {
return {
id: item.key,
@@ -0,0 +1 @@
export const BAR_CHART_MAXIMUM_VALUE_TICK_COUNT = 6;
@@ -0,0 +1 @@
export const BAR_CHART_MINIMUM_VALUE_TICK_COUNT = 2;
@@ -5,6 +5,12 @@ export const useBarChartTheme = () => {
return {
axis: {
domain: {
line: {
stroke: theme.border.color.light,
strokeWidth: 1,
},
},
ticks: {
line: {
stroke: theme.border.color.light,
@@ -1,6 +1,9 @@
import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout';
import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins';
import { getBarChartTickConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig';
import {
type BarChartTickConfig,
getBarChartTickConfig,
} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig';
import { truncateTickLabel } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/truncateTickLabel';
import {
formatGraphValue,
@@ -29,6 +32,8 @@ type GetBarChartAxisConfigsProps = {
yAxisLabel?: string;
formatOptions?: GraphValueFormatOptions;
axisFontSize?: number;
valueTickValues?: number[];
tickConfig?: BarChartTickConfig;
};
export const getBarChartAxisConfigs = ({
@@ -41,24 +46,32 @@ export const getBarChartAxisConfigs = ({
yAxisLabel,
formatOptions,
axisFontSize = 11,
valueTickValues,
tickConfig,
}: GetBarChartAxisConfigsProps) => {
const {
categoryTickValues,
numberOfValueTicks,
maxBottomAxisTickLabelLength,
maxLeftAxisTickLabelLength,
} = getBarChartTickConfig({
width,
height,
data,
indexBy,
xAxisLabel,
yAxisLabel,
axisFontSize,
layout,
});
} =
tickConfig ??
getBarChartTickConfig({
width,
height,
data,
indexBy,
xAxisLabel,
yAxisLabel,
axisFontSize,
layout,
});
const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout });
const resolvedValueTickValues =
valueTickValues && valueTickValues.length > 0
? valueTickValues
: numberOfValueTicks;
if (layout === BarChartLayout.VERTICAL) {
return {
@@ -72,7 +85,7 @@ export const getBarChartAxisConfigs = ({
},
axisLeft: {
...COMMON_AXIS_CONFIG,
tickValues: numberOfValueTicks,
tickValues: resolvedValueTickValues,
legend: yAxisLabel,
legendOffset: -margins.left + LEFT_AXIS_LEGEND_OFFSET_PADDING,
format: (value: number) =>
@@ -87,7 +100,7 @@ export const getBarChartAxisConfigs = ({
return {
axisBottom: {
...COMMON_AXIS_CONFIG,
tickValues: numberOfValueTicks,
tickValues: resolvedValueTickValues,
legend: yAxisLabel,
legendOffset: BOTTOM_AXIS_LEGEND_OFFSET,
format: (value: number) =>
@@ -1,3 +1,5 @@
import { BAR_CHART_MAXIMUM_VALUE_TICK_COUNT } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumValueTickCount';
import { BAR_CHART_MINIMUM_VALUE_TICK_COUNT } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMinimumValueTickCount';
import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout';
import { calculateMaxTickLabelLength } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateMaxTickLabelLength';
import { calculateWidthPerTick } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateWidthPerTick';
@@ -34,6 +36,12 @@ export const getBarChartTickConfig = ({
axisFontSize: number;
layout: BarChartLayout;
}): BarChartTickConfig => {
const clampValueTickCount = (tickCount: number) =>
Math.min(
BAR_CHART_MAXIMUM_VALUE_TICK_COUNT,
Math.max(BAR_CHART_MINIMUM_VALUE_TICK_COUNT, tickCount),
);
const categoryTickValues = computeBarChartCategoryTickValues({
axisSize: layout === BarChartLayout.VERTICAL ? width : height,
axisFontSize,
@@ -49,12 +57,14 @@ export const getBarChartTickConfig = ({
const availableWidth = width - (margins.left + margins.right);
const availableHeight = height - (margins.top + margins.bottom);
const numberOfValueTicks = computeBarChartValueTickCount({
axisSize:
layout === BarChartLayout.VERTICAL ? availableHeight : availableWidth,
axisFontSize,
layout,
});
const numberOfValueTicks = clampValueTickCount(
computeBarChartValueTickCount({
axisSize:
layout === BarChartLayout.VERTICAL ? availableHeight : availableWidth,
axisFontSize,
layout,
}),
);
const widthPerTick = calculateWidthPerTick({
layout,
@@ -44,13 +44,14 @@ type TransformGroupByDataToBarChartDataResult = {
formattedToRawLookup: Map<string, RawDimensionValue>;
};
const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = {
const EMPTY_BAR_CHART_RESULT: Omit<
TransformGroupByDataToBarChartDataResult,
'xAxisLabel' | 'yAxisLabel'
> = {
data: [],
indexBy: '',
keys: [],
series: [],
xAxisLabel: undefined,
yAxisLabel: undefined,
showDataLabels: false,
showLegend: true,
layout: BarChartLayout.VERTICAL,
@@ -65,10 +66,6 @@ export const transformGroupByDataToBarChartData = ({
configuration,
aggregateOperation,
}: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => {
if (!isDefined(groupByData)) {
return EMPTY_BAR_CHART_RESULT;
}
const groupByFieldX = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.primaryAxisGroupByFieldMetadataId,
@@ -88,13 +85,53 @@ export const transformGroupByDataToBarChartData = ({
field.id === configuration.aggregateFieldMetadataId,
);
const queryResultGqlFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const rawResults = groupByData?.[queryResultGqlFieldName];
const hasNoData =
!isDefined(groupByData) ||
!isDefined(rawResults) ||
!Array.isArray(rawResults) ||
rawResults.length === 0;
const showXAxis =
hasNoData ||
configuration.axisNameDisplay === AxisNameDisplay.X ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const showYAxis =
hasNoData ||
configuration.axisNameDisplay === AxisNameDisplay.Y ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const xAxisLabel =
showXAxis && isDefined(groupByFieldX) ? groupByFieldX.label : undefined;
const yAxisLabel =
showYAxis && isDefined(aggregateField)
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
: undefined;
const layout =
configuration.graphType === GraphType.HORIZONTAL_BAR
? BarChartLayout.HORIZONTAL
: BarChartLayout.VERTICAL;
if (!isDefined(groupByData)) {
return {
...EMPTY_BAR_CHART_RESULT,
xAxisLabel,
yAxisLabel,
layout,
};
}
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
return {
...EMPTY_BAR_CHART_RESULT,
layout:
configuration.graphType === GraphType.HORIZONTAL_BAR
? BarChartLayout.HORIZONTAL
: BarChartLayout.VERTICAL,
xAxisLabel,
yAxisLabel,
layout,
};
}
@@ -108,18 +145,13 @@ export const transformGroupByDataToBarChartData = ({
subFieldName: primaryAxisSubFieldName,
});
const queryResultGqlFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const rawResults = groupByData[queryResultGqlFieldName];
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
return {
...EMPTY_BAR_CHART_RESULT,
indexBy: indexByKey,
layout:
configuration.graphType === GraphType.HORIZONTAL_BAR
? BarChartLayout.HORIZONTAL
: BarChartLayout.VERTICAL,
xAxisLabel,
yAxisLabel,
layout,
};
}
@@ -141,20 +173,6 @@ export const transformGroupByDataToBarChartData = ({
objectMetadataItem,
});
const showXAxis =
configuration.axisNameDisplay === AxisNameDisplay.X ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const showYAxis =
configuration.axisNameDisplay === AxisNameDisplay.Y ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const xAxisLabel = showXAxis ? groupByFieldX.label : undefined;
const yAxisLabel = showYAxis
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
: undefined;
const showDataLabels = configuration.displayDataLabel ?? false;
const showLegend = configuration.displayLegend ?? true;
@@ -235,11 +253,6 @@ export const transformGroupByDataToBarChartData = ({
primaryAxisSubFieldName,
});
const layout =
configuration.graphType === GraphType.HORIZONTAL_BAR
? BarChartLayout.HORIZONTAL
: BarChartLayout.VERTICAL;
return {
...baseResult,
xAxisLabel,
@@ -1,5 +1,6 @@
import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer';
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
import { NoDataLayer } from '@/page-layout/widgets/graph/components/NoDataLayer';
import {
CustomCrosshairLayer,
type SliceHoverData,
@@ -19,6 +20,8 @@ import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLin
import { calculateValueRangeFromLineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries';
import { getLineChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig';
import { getLineChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig';
import { computeEffectiveValueRange } from '@/page-layout/widgets/graph/utils/computeEffectiveValueRange';
import { computeValueTickValues } from '@/page-layout/widgets/graph/utils/computeValueTickValues';
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
import {
formatGraphValue,
@@ -42,6 +45,9 @@ import { useDebouncedCallback } from 'use-debounce';
type CrosshairLayerProps = LineCustomSvgLayerProps<LineSeries>;
type PointLabelsLayerProps = LineCustomSvgLayerProps<LineSeries>;
type StackedAreasLayerProps = LineCustomSvgLayerProps<LineSeries>;
type NoDataLayerWrapperProps = LineCustomSvgLayerProps<LineSeries>;
const LINE_CHART_DEFAULT_TICK_COUNT = 5;
type GraphWidgetLineChartProps = {
data: LineChartSeries[];
@@ -103,8 +109,15 @@ export const GraphWidgetLineChart = ({
};
const calculatedValueRange = calculateValueRangeFromLineChartSeries(data);
const effectiveMinimumValue = rangeMin ?? calculatedValueRange.minimum;
const effectiveMaximumValue = rangeMax ?? calculatedValueRange.maximum;
const { effectiveMinimumValue, effectiveMaximumValue, hasNoData } =
computeEffectiveValueRange({
calculatedMinimum: calculatedValueRange.minimum,
calculatedMaximum: calculatedValueRange.maximum,
rangeMin,
rangeMax,
dataLength: data.length,
});
const { enrichedSeries, nivoData, colors, legendItems } = useLineChartData({
data,
@@ -164,41 +177,67 @@ export const GraphWidgetLineChart = ({
});
};
const PointLabelsLayer = (layerProps: PointLabelsLayerProps) => (
<CustomPointLabelsLayer
points={layerProps.points}
formatValue={(value) => formatGraphValue(value, formatOptions)}
offset={theme.spacingMultiplicator * 2}
groupMode={groupMode}
omitNullValues={_omitNullValues}
enablePointLabel={enablePointLabel}
/>
);
const PointLabelsLayer = (layerProps: PointLabelsLayerProps) => {
if (hasNoData) {
return null;
}
const CrosshairLayer = (layerProps: CrosshairLayerProps) => (
<CustomCrosshairLayer
key="custom-crosshair-layer"
points={layerProps.points}
innerHeight={layerProps.innerHeight}
return (
<CustomPointLabelsLayer
points={layerProps.points}
formatValue={(value) => formatGraphValue(value, formatOptions)}
offset={theme.spacingMultiplicator * 2}
groupMode={groupMode}
omitNullValues={_omitNullValues}
enablePointLabel={enablePointLabel}
/>
);
};
const CrosshairLayer = (layerProps: CrosshairLayerProps) => {
if (hasNoData) {
return null;
}
return (
<CustomCrosshairLayer
key="custom-crosshair-layer"
points={layerProps.points}
innerHeight={layerProps.innerHeight}
innerWidth={layerProps.innerWidth}
onSliceHover={handleSliceEnter}
onSliceClick={
isDefined(onSliceClick)
? (sliceData) => onSliceClick(sliceData.closestPoint)
: undefined
}
onRectLeave={handleSliceLeave}
/>
);
};
const StackedAreasLayer = (layerProps: StackedAreasLayerProps) => {
if (hasNoData) {
return null;
}
return (
<CustomStackedAreasLayer
series={layerProps.series}
innerHeight={layerProps.innerHeight}
enrichedSeries={enrichedSeries}
enableArea={enableArea}
yScale={layerProps.yScale}
isStacked={groupMode === 'stacked'}
/>
);
};
const NoDataLayerWrapper = (layerProps: NoDataLayerWrapperProps) => (
<NoDataLayer
innerWidth={layerProps.innerWidth}
onSliceHover={handleSliceEnter}
onSliceClick={
isDefined(onSliceClick)
? (sliceData) => onSliceClick(sliceData.closestPoint)
: undefined
}
onRectLeave={handleSliceLeave}
/>
);
const StackedAreasLayer = (layerProps: StackedAreasLayerProps) => (
<CustomStackedAreasLayer
series={layerProps.series}
innerHeight={layerProps.innerHeight}
enrichedSeries={enrichedSeries}
enableArea={enableArea}
yScale={layerProps.yScale}
isStacked={groupMode === 'stacked'}
hasNoData={hasNoData}
/>
);
@@ -207,7 +246,25 @@ export const GraphWidgetLineChart = ({
chartWidth,
data,
);
const axisLeftConfig = getLineChartAxisLeftConfig(yAxisLabel, formatOptions);
const chartMargins = {
top: LINE_CHART_MARGIN_TOP,
right: LINE_CHART_MARGIN_RIGHT,
bottom: LINE_CHART_MARGIN_BOTTOM,
left: LINE_CHART_MARGIN_LEFT,
};
const { tickValues: valueTickValues, domain: valueDomain } =
computeValueTickValues({
minimum: effectiveMinimumValue,
maximum: effectiveMaximumValue,
tickCount: LINE_CHART_DEFAULT_TICK_COUNT,
});
const axisLeftConfig = getLineChartAxisLeftConfig(
yAxisLabel,
formatOptions,
valueTickValues,
chartMargins.left,
);
return (
<StyledContainer id={id}>
@@ -225,16 +282,16 @@ export const GraphWidgetLineChart = ({
<ResponsiveLine
data={nivoData}
margin={{
top: LINE_CHART_MARGIN_TOP,
right: LINE_CHART_MARGIN_RIGHT,
bottom: LINE_CHART_MARGIN_BOTTOM,
left: LINE_CHART_MARGIN_LEFT,
top: chartMargins.top,
right: chartMargins.right,
bottom: chartMargins.bottom,
left: chartMargins.left,
}}
xScale={{ type: 'point' }}
yScale={{
type: 'linear',
min: effectiveMinimumValue,
max: effectiveMaximumValue,
min: valueDomain.min,
max: valueDomain.max,
stacked: groupMode === 'stacked',
clamp: true,
}}
@@ -251,6 +308,7 @@ export const GraphWidgetLineChart = ({
axisLeft={axisLeftConfig}
enableGridX={showGrid}
enableGridY={showGrid}
gridYValues={valueTickValues}
enableSlices={'x'}
sliceTooltip={() => null}
tooltip={() => null}
@@ -264,6 +322,7 @@ export const GraphWidgetLineChart = ({
'points',
PointLabelsLayer,
'legends',
NoDataLayerWrapper,
]}
theme={chartTheme}
/>
@@ -276,7 +335,7 @@ export const GraphWidgetLineChart = ({
onMouseEnter={handleTooltipMouseEnter}
onMouseLeave={handleTooltipMouseLeave}
/>
<GraphWidgetLegend show={showLegend} items={legendItems} />
<GraphWidgetLegend show={showLegend && !hasNoData} items={legendItems} />
</StyledContainer>
);
};
@@ -1 +1 @@
export const LINE_CHART_MARGIN_LEFT = 70;
export const LINE_CHART_MARGIN_LEFT = 80;
@@ -1,17 +1,24 @@
import { LINE_CHART_MARGIN_LEFT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
const LEFT_AXIS_LEGEND_OFFSET_PADDING = 5;
const TICK_PADDING = 5;
export const getLineChartAxisLeftConfig = (
yAxisLabel?: string,
formatOptions?: GraphValueFormatOptions,
tickValues?: number[],
marginLeft: number = LINE_CHART_MARGIN_LEFT,
) => ({
tickSize: 0,
tickPadding: 5,
tickPadding: TICK_PADDING,
tickRotation: 0,
tickValues,
legend: yAxisLabel,
legendPosition: 'middle' as const,
legendOffset: -50,
legendOffset: -marginLeft + LEFT_AXIS_LEGEND_OFFSET_PADDING,
format: (value: number) => formatGraphValue(value, formatOptions || {}),
});
@@ -174,6 +174,7 @@ export const GraphWidgetPieChart = ({
objectMetadataItemId={objectMetadataItemId}
configuration={configuration}
show={showCenterMetric && !hasNoData}
hasNoData={hasNoData}
/>
</StyledPieChartWrapper>
</GraphWidgetChartContainer>
@@ -1,6 +1,7 @@
import { usePieChartCenterMetricData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartCenterMetricData';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { type PieChartConfiguration } from '~/generated/graphql';
@@ -8,6 +9,7 @@ type PieChartCenterMetricProps = {
objectMetadataItemId: string;
configuration: PieChartConfiguration;
show: boolean;
hasNoData?: boolean;
};
const StyledCenterMetricContainer = styled(motion.div)`
@@ -33,22 +35,31 @@ const StyledLabel = styled.span`
font-size: clamp(10px, 5cqmin, 24px);
`;
const StyledNoDataText = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
`;
export const PieChartCenterMetric = ({
objectMetadataItemId,
configuration,
show,
hasNoData = false,
}: PieChartCenterMetricProps) => {
const theme = useTheme();
const { t } = useLingui();
const { centerMetricValue, centerMetricLabel } = usePieChartCenterMetricData({
objectMetadataItemId,
configuration,
skip: !show,
skip: !show && !hasNoData,
});
const shouldShowContent = show || hasNoData;
return (
<AnimatePresence>
{show && (
{shouldShowContent && (
<StyledCenterMetricContainer
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -58,8 +69,14 @@ export const PieChartCenterMetric = ({
ease: 'easeInOut',
}}
>
<StyledValue>{centerMetricValue}</StyledValue>
<StyledLabel>{centerMetricLabel}</StyledLabel>
{hasNoData ? (
<StyledNoDataText>{t`No data`}</StyledNoDataText>
) : (
<>
<StyledValue>{centerMetricValue}</StyledValue>
<StyledLabel>{centerMetricLabel}</StyledLabel>
</>
)}
</StyledCenterMetricContainer>
)}
</AnimatePresence>
@@ -0,0 +1,59 @@
import { computeEffectiveValueRange } from '../computeEffectiveValueRange';
describe('computeEffectiveValueRange', () => {
it('should return hasNoData true when dataLength is 0', () => {
const result = computeEffectiveValueRange({
calculatedMinimum: 0,
calculatedMaximum: 0,
dataLength: 0,
});
expect(result.hasNoData).toBe(true);
});
it('should start from 0 for non-negative values', () => {
const result = computeEffectiveValueRange({
calculatedMinimum: 5,
calculatedMaximum: 100,
dataLength: 10,
});
expect(result.effectiveMinimumValue).toBe(0);
expect(result.hasNoData).toBe(false);
});
it('should use calculated minimum for negative values', () => {
const result = computeEffectiveValueRange({
calculatedMinimum: -50,
calculatedMaximum: 100,
dataLength: 10,
});
expect(result.effectiveMinimumValue).toBe(-50);
});
it('should respect explicit rangeMin and rangeMax', () => {
const result = computeEffectiveValueRange({
calculatedMinimum: 0,
calculatedMaximum: 100,
rangeMin: 10,
rangeMax: 50,
dataLength: 10,
});
expect(result.effectiveMinimumValue).toBe(10);
expect(result.effectiveMaximumValue).toBe(50);
});
it('should add padding when min equals max and no explicit range', () => {
const result = computeEffectiveValueRange({
calculatedMinimum: 50,
calculatedMaximum: 50,
dataLength: 1,
});
expect(result.effectiveMaximumValue).toBeGreaterThan(
result.effectiveMinimumValue,
);
});
});
@@ -0,0 +1,49 @@
import { computeValueTickValues } from '../computeValueTickValues';
describe('computeValueTickValues', () => {
it('should return empty array for non-finite inputs', () => {
const result = computeValueTickValues({
minimum: Infinity,
maximum: 100,
tickCount: 5,
});
expect(result.tickValues).toEqual([]);
expect(result.domain).toEqual({ min: 0, max: 0 });
});
it('should return single tick when min equals max', () => {
const result = computeValueTickValues({
minimum: 50,
maximum: 50,
tickCount: 5,
});
expect(result.tickValues).toEqual([50]);
expect(result.domain).toEqual({ min: 50, max: 50 });
});
it('should generate nice tick values for positive range', () => {
const result = computeValueTickValues({
minimum: 0,
maximum: 100,
tickCount: 5,
});
expect(result.tickValues.length).toBeGreaterThanOrEqual(2);
expect(result.domain.min).toBeLessThanOrEqual(0);
expect(result.domain.max).toBeGreaterThanOrEqual(100);
});
it('should handle negative ranges', () => {
const result = computeValueTickValues({
minimum: -100,
maximum: -10,
tickCount: 5,
});
expect(result.tickValues.length).toBeGreaterThanOrEqual(2);
expect(result.domain.min).toBeLessThanOrEqual(-100);
expect(result.domain.max).toBeGreaterThanOrEqual(-10);
});
});
@@ -0,0 +1,65 @@
import { isDefined } from 'twenty-shared/utils';
const POSITIVE_RANGE_PADDING_RATIO = 0.1;
const MINIMUM_POSITIVE_RANGE_PADDING = 1;
type ComputeEffectiveValueRangeParams = {
calculatedMinimum: number;
calculatedMaximum: number;
rangeMin?: number;
rangeMax?: number;
dataLength: number;
};
type EffectiveValueRangeResult = {
effectiveMinimumValue: number;
effectiveMaximumValue: number;
hasNoData: boolean;
};
export const computeEffectiveValueRange = ({
calculatedMinimum,
calculatedMaximum,
rangeMin,
rangeMax,
dataLength,
}: ComputeEffectiveValueRangeParams): EffectiveValueRangeResult => {
const hasOnlyNonNegativeValues =
calculatedMinimum >= 0 && calculatedMaximum >= 0;
const hasNoData = dataLength === 0;
const baseMinimumValue = isDefined(rangeMin)
? rangeMin
: hasOnlyNonNegativeValues
? 0
: calculatedMinimum;
const positiveRangePaddingTarget = isDefined(rangeMax)
? rangeMax
: calculatedMaximum;
const paddedMaximumForNonNegative =
isDefined(rangeMax) || !hasOnlyNonNegativeValues
? positiveRangePaddingTarget
: positiveRangePaddingTarget +
Math.max(
Math.abs(positiveRangePaddingTarget) * POSITIVE_RANGE_PADDING_RATIO,
MINIMUM_POSITIVE_RANGE_PADDING,
);
let effectiveMinimumValue = baseMinimumValue;
let effectiveMaximumValue = paddedMaximumForNonNegative;
if (!isDefined(rangeMax) && !isDefined(rangeMin)) {
if (effectiveMinimumValue === effectiveMaximumValue) {
effectiveMaximumValue =
effectiveMinimumValue + MINIMUM_POSITIVE_RANGE_PADDING;
}
}
return {
effectiveMinimumValue,
effectiveMaximumValue,
hasNoData,
};
};
@@ -0,0 +1,71 @@
import { NICE_STEP_MULTIPLIERS } from '@/page-layout/widgets/graph/constants/NiceStepMultipliers';
import { NICE_STEP_NORMALIZED_VALUE_THRESHOLDS } from '@/page-layout/widgets/graph/constants/NiceStepNormalizedValueThresholds';
const computeNiceStepInterval = (roughStepInterval: number): number => {
if (roughStepInterval === 0) {
return 0;
}
const stepMagnitude = Math.pow(
10,
Math.floor(Math.log10(Math.abs(roughStepInterval))),
);
const normalizedStepValue = roughStepInterval / stepMagnitude;
if (normalizedStepValue >= NICE_STEP_NORMALIZED_VALUE_THRESHOLDS.LARGE)
return NICE_STEP_MULTIPLIERS.LARGE * stepMagnitude;
if (normalizedStepValue >= NICE_STEP_NORMALIZED_VALUE_THRESHOLDS.MEDIUM)
return NICE_STEP_MULTIPLIERS.MEDIUM * stepMagnitude;
if (normalizedStepValue >= NICE_STEP_NORMALIZED_VALUE_THRESHOLDS.SMALL)
return NICE_STEP_MULTIPLIERS.SMALL * stepMagnitude;
return NICE_STEP_MULTIPLIERS.DEFAULT * stepMagnitude;
};
export const computeValueTickValues = ({
minimum,
maximum,
tickCount,
}: {
minimum: number;
maximum: number;
tickCount: number;
}): {
tickValues: number[];
domain: { min: number; max: number };
} => {
if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) {
return { tickValues: [], domain: { min: 0, max: 0 } };
}
if (minimum === maximum) {
return { tickValues: [minimum], domain: { min: minimum, max: minimum } };
}
const safeTickCount = Math.max(2, tickCount);
const roughStepInterval = (maximum - minimum) / (safeTickCount - 1);
const niceStepInterval = computeNiceStepInterval(roughStepInterval);
if (niceStepInterval === 0) {
return {
tickValues: [minimum, maximum],
domain: { min: minimum, max: maximum },
};
}
const niceMinimum = Math.floor(minimum / niceStepInterval) * niceStepInterval;
const niceMaximum = Math.ceil(maximum / niceStepInterval) * niceStepInterval;
const tickValues: number[] = [];
for (
let tickValue = niceMinimum;
tickValue <= niceMaximum + niceStepInterval / 2;
tickValue += niceStepInterval
) {
tickValues.push(Number(tickValue.toFixed(12)));
}
return {
tickValues,
domain: { min: niceMinimum, max: niceMaximum },
};
};
@@ -34,10 +34,11 @@ type TransformGroupByDataToLineChartDataResult = {
formattedToRawLookup: Map<string, RawDimensionValue>;
};
const EMPTY_LINE_CHART_RESULT: TransformGroupByDataToLineChartDataResult = {
const EMPTY_LINE_CHART_RESULT: Omit<
TransformGroupByDataToLineChartDataResult,
'xAxisLabel' | 'yAxisLabel'
> = {
series: [],
xAxisLabel: undefined,
yAxisLabel: undefined,
showDataLabels: false,
showLegend: true,
hasTooManyGroups: false,
@@ -51,10 +52,6 @@ export const transformGroupByDataToLineChartData = ({
configuration,
aggregateOperation,
}: TransformGroupByDataToLineChartDataParams): TransformGroupByDataToLineChartDataResult => {
if (!isDefined(groupByData)) {
return EMPTY_LINE_CHART_RESULT;
}
const groupByFieldX = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.primaryAxisGroupByFieldMetadataId,
@@ -74,8 +71,47 @@ export const transformGroupByDataToLineChartData = ({
field.id === configuration.aggregateFieldMetadataId,
);
const queryResultGqlFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const rawResults = groupByData?.[queryResultGqlFieldName];
const hasNoData =
!isDefined(groupByData) ||
!isDefined(rawResults) ||
!Array.isArray(rawResults) ||
rawResults.length === 0;
const showXAxis =
hasNoData ||
configuration.axisNameDisplay === AxisNameDisplay.X ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const showYAxis =
hasNoData ||
configuration.axisNameDisplay === AxisNameDisplay.Y ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const xAxisLabel =
showXAxis && isDefined(groupByFieldX) ? groupByFieldX.label : undefined;
const yAxisLabel =
showYAxis && isDefined(aggregateField)
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
: undefined;
if (!isDefined(groupByData)) {
return {
...EMPTY_LINE_CHART_RESULT,
xAxisLabel,
yAxisLabel,
};
}
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
return EMPTY_LINE_CHART_RESULT;
return {
...EMPTY_LINE_CHART_RESULT,
xAxisLabel,
yAxisLabel,
};
}
const primaryAxisSubFieldName =
@@ -83,12 +119,12 @@ export const transformGroupByDataToLineChartData = ({
const secondaryAxisSubFieldName =
configuration.secondaryAxisGroupBySubFieldName ?? undefined;
const queryResultGqlFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const rawResults = groupByData[queryResultGqlFieldName];
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
return EMPTY_LINE_CHART_RESULT;
return {
...EMPTY_LINE_CHART_RESULT,
xAxisLabel,
yAxisLabel,
};
}
const filteredResults = filterGroupByResults({
@@ -109,20 +145,6 @@ export const transformGroupByDataToLineChartData = ({
objectMetadataItem,
});
const showXAxis =
configuration.axisNameDisplay === AxisNameDisplay.X ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const showYAxis =
configuration.axisNameDisplay === AxisNameDisplay.Y ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const xAxisLabel = showXAxis ? groupByFieldX.label : undefined;
const yAxisLabel = showYAxis
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
: undefined;
const showDataLabels = configuration.displayDataLabel ?? false;
const showLegend = configuration.displayLegend ?? true;