nitin
2025-11-13 17:05:08 +05:30
committed by GitHub
parent eee35e5e25
commit fddac24e7f
71 changed files with 2375 additions and 790 deletions
@@ -1691,6 +1691,7 @@ export type LineChartConfiguration = {
filter?: Maybe<Scalars['JSON']>;
firstDayOfTheWeek?: Maybe<Scalars['Int']>;
graphType: GraphType;
isStacked?: Maybe<Scalars['Boolean']>;
omitNullValues?: Maybe<Scalars['Boolean']>;
primaryAxisDateGranularity?: Maybe<ObjectRecordGroupByDateGranularity>;
primaryAxisGroupByFieldMetadataId: Scalars['UUID'];
@@ -1625,6 +1625,7 @@ export type LineChartConfiguration = {
filter?: Maybe<Scalars['JSON']>;
firstDayOfTheWeek?: Maybe<Scalars['Int']>;
graphType: GraphType;
isStacked?: Maybe<Scalars['Boolean']>;
omitNullValues?: Maybe<Scalars['Boolean']>;
primaryAxisDateGranularity?: Maybe<ObjectRecordGroupByDateGranularity>;
primaryAxisGroupByFieldMetadataId: Scalars['UUID'];
@@ -4656,7 +4657,7 @@ export type SearchQueryVariables = Exact<{
export type SearchQuery = { __typename?: 'Query', search: { __typename?: 'SearchResultConnection', edges: Array<{ __typename?: 'SearchResultEdge', cursor: string, node: { __typename?: 'SearchRecord', recordId: any, objectNameSingular: string, label: string, imageUrl?: string | null, tsRankCD: number, tsRank: number } }>, pageInfo: { __typename?: 'SearchResultPageInfo', hasNextPage: boolean, endCursor?: string | null } } };
export type PageLayoutWidgetFragmentFragment = { __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | null };
export type PageLayoutWidgetFragmentFragment = { __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | null };
export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{
id: Scalars['String'];
@@ -4664,7 +4665,7 @@ export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{
}>;
export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: any, name: string, type: PageLayoutType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: any, title: string, position: number, pageLayoutId: any, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | null }> | null }> | null } };
export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: any, name: string, type: PageLayoutType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: any, title: string, position: number, pageLayoutId: any, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | null }> | null }> | null } };
export type OnDbEventSubscriptionVariables = Exact<{
input: OnDbEventInput;
@@ -5019,6 +5020,7 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql`
color
description
filter
isStacked
timezone
firstDayOfTheWeek
}
@@ -18,6 +18,7 @@ import { shouldHideChartSetting } from '@/command-menu/pages/page-layout/utils/s
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumNumberOfBars.constant';
import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant';
import { hasWidgetTooManyGroupsComponentState } from '@/page-layout/widgets/graph/states/hasWidgetTooManyGroupsComponentState';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
@@ -89,7 +90,8 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
if (
graphType !== GraphType.VERTICAL_BAR &&
graphType !== GraphType.HORIZONTAL_BAR
graphType !== GraphType.HORIZONTAL_BAR &&
graphType !== GraphType.LINE
) {
setHasWidgetTooManyGroups(false);
}
@@ -124,7 +126,11 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
/>
{hasWidgetTooManyGroups && (
<StyledSidePanelInformationBanner
message={t`Max ${BAR_CHART_MAXIMUM_NUMBER_OF_BARS} bars per chart. Consider adding a filter`}
message={
currentGraphType === GraphType.LINE
? t`Max ${LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS} data points per chart. Consider adding a filter`
: t`Max ${BAR_CHART_MAXIMUM_NUMBER_OF_BARS} bars per chart. Consider adding a filter`
}
/>
)}
{chartSettings.map((group) => {
@@ -104,19 +104,29 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
[subFieldNameKey]: subFieldName,
};
if (
!isSecondaryAxis ||
configuration.__typename !== 'BarChartConfiguration'
) {
if (!isSecondaryAxis) {
return baseConfig;
}
return {
...baseConfig,
groupMode: isDefined(fieldId)
? (configuration.groupMode ?? BarChartGroupMode.STACKED)
: null,
};
if (configuration.__typename === 'BarChartConfiguration') {
return {
...baseConfig,
groupMode: isDefined(fieldId)
? (configuration.groupMode ?? BarChartGroupMode.STACKED)
: null,
};
}
if (configuration.__typename === 'LineChartConfiguration') {
return {
...baseConfig,
isStacked: isDefined(fieldId)
? (configuration.isStacked ?? true)
: null,
};
}
return baseConfig;
};
const handleSelectField = (fieldMetadataItem: FieldMetadataItem) => {
@@ -56,14 +56,22 @@ export const ChartXAxisSortBySelectionDropdownContent = () => {
closeDropdown();
};
const isLineChart = configuration.__typename === 'LineChartConfiguration';
const availableOptions = X_SORT_BY_OPTIONS.filter((option) => {
if (isLineChart) {
return option.value !== 'VALUE_ASC' && option.value !== 'VALUE_DESC';
}
return true;
});
return (
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={X_SORT_BY_OPTIONS.map((option) => option.value)}
selectableItemIdArray={availableOptions.map((option) => option.value)}
>
{X_SORT_BY_OPTIONS.map((sortOption) => (
{availableOptions.map((sortOption) => (
<SelectableListItem
key={sortOption.value}
itemId={sortOption.value}
@@ -13,6 +13,7 @@ import { RANGE_MAX_SETTING } from '@/command-menu/pages/page-layout/constants/se
import { RANGE_MIN_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMinSetting';
import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting';
import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting';
import { STACKED_LINES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedLineSettings';
import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
export const LINE_CHART_SETTINGS: ChartSettingsGroup[] = [
@@ -42,6 +43,11 @@ export const LINE_CHART_SETTINGS: ChartSettingsGroup[] = [
},
{
heading: 'Style',
items: [COLORS_SETTING, AXIS_NAME_SETTING, DATA_LABELS_SETTING],
items: [
COLORS_SETTING,
AXIS_NAME_SETTING,
STACKED_LINES_SETTING,
DATA_LABELS_SETTING,
],
},
];
@@ -15,6 +15,7 @@ export const CHART_CONFIGURATION_SETTING_LABELS = {
EACH_SLICE_REPRESENTS: msg`Each slice represents`,
AXIS_NAME: msg`Axis name`,
STACKED_BARS: msg`Stacked bars`,
STACKED_LINES: msg`Stacked lines`,
OMIT_NULL_VALUES: msg`Omit zero values`,
MIN_RANGE: msg`Min range`,
MAX_RANGE: msg`Max range`,
@@ -0,0 +1,12 @@
import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconStack2 } from 'twenty-ui/display';
export const STACKED_LINES_SETTING: ChartSettingsItem = {
isBoolean: true,
Icon: IconStack2,
label: CHART_CONFIGURATION_SETTING_LABELS.STACKED_LINES,
id: CHART_CONFIGURATION_SETTING_IDS.STACKED_LINES,
dependsOn: [CHART_CONFIGURATION_SETTING_IDS.GROUP_BY],
};
@@ -159,9 +159,13 @@ export const useChartSettingsValues = ({
case CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS:
return configuration.displayDataLabel ?? undefined;
case CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS:
return 'groupMode' in configuration
return configuration.__typename === 'BarChartConfiguration'
? configuration.groupMode !== 'GROUPED'
: true;
case CHART_CONFIGURATION_SETTING_IDS.STACKED_LINES:
return configuration.__typename === 'LineChartConfiguration'
? configuration.isStacked !== false
: true;
case CHART_CONFIGURATION_SETTING_IDS.OMIT_NULL_VALUES:
return 'omitNullValues' in configuration
? (configuration.omitNullValues ?? false)
@@ -41,6 +41,17 @@ export const useUpdateChartSettingToggle = ({
return;
}
if (settingId === CHART_CONFIGURATION_SETTING_IDS.STACKED_LINES) {
const isCurrentlyStacked = getChartSettingsValues(settingId);
updateCurrentWidgetConfig({
configToUpdate: {
isStacked: !isCurrentlyStacked,
},
});
return;
}
const newValue = !getChartSettingsValues(settingId);
updateCurrentWidgetConfig({
@@ -14,6 +14,7 @@ export enum CHART_CONFIGURATION_SETTING_IDS {
EACH_SLICE_REPRESENTS = 'EACH_SLICE_REPRESENTS',
AXIS_NAME = 'AXIS_NAME',
STACKED_BARS = 'STACKED_BARS',
STACKED_LINES = 'STACKED_LINES',
OMIT_NULL_VALUES = 'OMIT_NULL_VALUES',
MIN_RANGE = 'MIN_RANGE',
MAX_RANGE = 'MAX_RANGE',
@@ -25,6 +26,7 @@ export enum CHART_CONFIGURATION_SETTING_IDS {
export const CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP = {
[CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS]: 'displayDataLabel',
[CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS]: 'groupMode',
[CHART_CONFIGURATION_SETTING_IDS.STACKED_LINES]: 'isStacked',
[CHART_CONFIGURATION_SETTING_IDS.OMIT_NULL_VALUES]: 'omitNullValues',
[CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE]: 'rangeMin',
[CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE]: 'rangeMax',
@@ -60,6 +60,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
color
description
filter
isStacked
timezone
firstDayOfTheWeek
}
@@ -58,7 +58,7 @@ export const WidgetPlaceholder = () => {
onClick={handleClick}
>
<WidgetCardHeader
isWidgetCardHovered={false}
widgetId="widget-placeholder"
isInEditMode={isPageLayoutInEditMode}
isResizing={false}
title={t`Add Widget`}
@@ -14,8 +14,10 @@ import { WidgetCard } from '@/page-layout/widgets/widget-card/components/WidgetC
import { WidgetCardContent } from '@/page-layout/widgets/widget-card/components/WidgetCardContent';
import { WidgetCardHeader } from '@/page-layout/widgets/widget-card/components/WidgetCardHeader';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentFamilyState';
import { widgetCardHoveredComponentFamilyState } from '@/page-layout/widgets/states/widgetCardHoveredComponentFamilyState';
import { useTheme } from '@emotion/react';
import { useState, type MouseEvent } from 'react';
import { type MouseEvent } from 'react';
import { IconLock } from 'twenty-ui/display';
import { PageLayoutType, type PageLayoutWidget } from '~/generated/graphql';
@@ -71,7 +73,10 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
deletePageLayoutWidget(widget.id);
};
const [isHovered, setIsHovered] = useState(false);
const setIsHovered = useSetRecoilComponentFamilyState(
widgetCardHoveredComponentFamilyState,
widget.id,
);
const handleMouseEnter = () => {
setIsHovered(true);
@@ -95,7 +100,7 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
>
{showHeader && (
<WidgetCardHeader
isWidgetCardHovered={isHovered}
widgetId={widget.id}
isInEditMode={isPageLayoutInEditMode}
isResizing={isResizing}
title={widget.title}
@@ -0,0 +1,26 @@
import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext';
import { type ReactNode } from 'react';
import { RecoilRoot, type MutableSnapshot } from 'recoil';
export const GRAPH_WIDGET_TEST_INSTANCE_ID =
'30303030-f244-4ae0-906b-78958aa07642';
export const GraphWidgetTestWrapper = ({
children,
initializeState,
instanceId: instanceIdFromProps,
}: {
children: ReactNode;
initializeState?: (snapshot: MutableSnapshot) => void;
instanceId?: string;
}) => {
const instanceId = instanceIdFromProps ?? GRAPH_WIDGET_TEST_INSTANCE_ID;
return (
<RecoilRoot initializeState={initializeState}>
<GraphWidgetComponentInstanceContext.Provider value={{ instanceId }}>
{children}
</GraphWidgetComponentInstanceContext.Provider>
</RecoilRoot>
);
};
@@ -4,18 +4,11 @@ import { PageLayoutWidgetNoDataDisplay } from '@/page-layout/widgets/components/
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { GraphWidgetAggregateChartRenderer } from '@/page-layout/widgets/graph/graphWidgetAggregateChart/components/GraphWidgetAggregateChartRenderer';
import { GraphWidgetBarChartRenderer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer';
import { GraphWidgetLineChartRenderer } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer';
import { areChartConfigurationFieldsValidForQuery } from '@/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery';
import { lazy, Suspense } from 'react';
import { GraphType, type PageLayoutWidget } from '~/generated/graphql';
const GraphWidgetLineChart = lazy(() =>
import(
'@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart'
).then((module) => ({
default: module.GraphWidgetLineChart,
})),
);
const GraphWidgetPieChart = lazy(() =>
import(
'@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart'
@@ -100,28 +93,7 @@ export const GraphWidget = ({
return <GraphWidgetBarChartRenderer widget={widget} />;
case GraphType.LINE:
return (
<Suspense fallback={<ChartSkeletonLoader />}>
<GraphWidgetLineChart
id={`line-chart-${widget.id}`}
data={data.series}
enableArea={data.enableArea}
showLegend={data.showLegend}
showGrid={data.showGrid}
enablePoints={data.enablePoints}
xAxisLabel={data.xAxisLabel}
yAxisLabel={data.yAxisLabel}
displayType={data.displayType}
prefix={data.prefix}
suffix={data.suffix}
xScale={data.xScale}
yScale={data.yScale}
curve={data.curve}
stackedArea={data.stackedArea}
enableSlices={'x'}
/>
</Suspense>
);
return <GraphWidgetLineChartRenderer widget={widget} />;
default:
return null;
@@ -2,13 +2,13 @@ import { hasWidgetTooManyGroupsComponentState } from '@/page-layout/widgets/grap
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useEffect } from 'react';
type GraphWidgetBarChartHasTooManyGroupsEffectProps = {
type GraphWidgetChartHasTooManyGroupsEffectProps = {
hasTooManyGroups: boolean;
};
export const GraphWidgetBarChartHasTooManyGroupsEffect = ({
export const GraphWidgetChartHasTooManyGroupsEffect = ({
hasTooManyGroups,
}: GraphWidgetBarChartHasTooManyGroupsEffectProps) => {
}: GraphWidgetChartHasTooManyGroupsEffectProps) => {
const setHasWidgetTooManyGroups = useSetRecoilComponentState(
hasWidgetTooManyGroupsComponentState,
);
@@ -1,5 +1,6 @@
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 { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
@@ -7,7 +8,14 @@ import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof GraphWidgetBarChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart',
component: GraphWidgetBarChart,
decorators: [ComponentDecorator],
decorators: [
(Story) => (
<GraphWidgetTestWrapper>
<Story />
</GraphWidgetTestWrapper>
),
ComponentDecorator,
],
parameters: {
layout: 'centered',
},
@@ -1,13 +1,21 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart';
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 { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof GraphWidgetLineChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetLineChart',
component: GraphWidgetLineChart,
decorators: [ComponentDecorator],
decorators: [
(Story) => (
<GraphWidgetTestWrapper>
<Story />
</GraphWidgetTestWrapper>
),
ComponentDecorator,
],
parameters: {
layout: 'centered',
},
@@ -34,7 +42,7 @@ const meta: Meta<typeof GraphWidgetLineChart> = {
showGrid: {
control: 'boolean',
},
enablePoints: {
enablePointLabel: {
control: 'boolean',
},
xAxisLabel: {
@@ -43,29 +51,14 @@ const meta: Meta<typeof GraphWidgetLineChart> = {
yAxisLabel: {
control: 'text',
},
enableArea: {
control: 'boolean',
},
stackedArea: {
control: 'boolean',
},
curve: {
control: 'select',
options: [
'linear',
'monotoneX',
'step',
'stepBefore',
'stepAfter',
'natural',
],
},
lineWidth: {
rangeMin: {
control: 'number',
},
enableSlices: {
control: 'select',
options: ['x', 'y', false],
rangeMax: {
control: 'number',
},
omitNullValues: {
control: 'boolean',
},
},
};
@@ -85,20 +78,16 @@ const renderChart = (args: ChartArgs) => (
data={args.data}
showLegend={args.showLegend}
showGrid={args.showGrid}
enablePoints={args.enablePoints}
enablePointLabel={args.enablePointLabel}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
rangeMin={args.rangeMin}
rangeMax={args.rangeMax}
omitNullValues={args.omitNullValues}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
enableArea={args.enableArea}
stackedArea={args.stackedArea}
curve={args.curve}
lineWidth={args.lineWidth}
enableSlices={args.enableSlices}
xScale={args.xScale}
yScale={args.yScale}
/>
</Container>
);
@@ -129,7 +118,6 @@ export const Default: Story = {
],
showLegend: true,
showGrid: true,
enablePoints: false,
xAxisLabel: 'Month',
yAxisLabel: 'Value',
displayType: 'shortNumber',
@@ -156,10 +144,8 @@ export const WithArea: Story = {
data: generateLinearData(12),
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: false,
xAxisLabel: 'Period',
yAxisLabel: 'Amount',
displayType: 'currency',
@@ -190,46 +176,15 @@ export const StackedArea: Story = {
data: generateLinearData(8),
},
],
enableArea: true,
stackedArea: true,
showLegend: true,
showGrid: true,
enablePoints: false,
curve: 'monotoneX',
xAxisLabel: 'Quarter',
yAxisLabel: 'Revenue',
yScale: {
type: 'linear',
min: 0,
max: 'auto',
},
displayType: 'shortNumber',
prefix: '$',
},
};
export const WithPoints: Story = {
render: renderChart,
args: {
id: 'line-chart-points',
data: [
{
id: 'performance',
label: 'Performance',
color: 'pink',
data: generateLinearData(8),
},
],
showLegend: true,
showGrid: true,
enablePoints: true,
lineWidth: 3,
xAxisLabel: 'Week',
yAxisLabel: 'Score',
displayType: 'percentage',
},
};
export const StepChart: Story = {
render: renderChart,
args: {
@@ -242,11 +197,8 @@ export const StepChart: Story = {
data: generateLinearData(10),
},
],
curve: 'step',
showLegend: true,
showGrid: true,
enablePoints: false,
lineWidth: 2,
xAxisLabel: 'Day',
yAxisLabel: 'Units',
displayType: 'number',
@@ -287,15 +239,8 @@ export const LogScaleDemo: Story = {
],
showLegend: true,
showGrid: true,
enablePoints: true,
xAxisLabel: 'Time',
yAxisLabel: 'Value (log scale)',
yScale: {
type: 'log',
base: 10,
min: 'auto',
max: 'auto',
},
displayType: 'shortNumber',
},
};
@@ -322,42 +267,12 @@ export const WithNullValues: Story = {
],
showLegend: true,
showGrid: true,
enablePoints: true,
xAxisLabel: 'Time',
yAxisLabel: 'Measurement',
displayType: 'number',
},
};
export const InteractiveWithLinks: Story = {
render: renderChart,
args: {
id: 'line-chart-interactive',
data: [
{
id: 'clickable',
label: 'Click Points',
color: 'turquoise',
data: [
{ x: 0, y: 30, to: '#point-0' },
{ x: 1, y: 45, to: '#point-1' },
{ x: 2, y: 38, to: '#point-2' },
{ x: 3, y: 52, to: '#point-3' },
{ x: 4, y: 48, to: '#point-4' },
{ x: 5, y: 60, to: '#point-5' },
],
},
],
showLegend: true,
showGrid: true,
enablePoints: true,
enableSlices: 'x',
xAxisLabel: 'Step',
yAxisLabel: 'Progress',
displayType: 'percentage',
},
};
export const MultiSeriesMixed: Story = {
render: renderChart,
args: {
@@ -368,14 +283,12 @@ export const MultiSeriesMixed: Story = {
label: 'Actual',
color: 'blue',
data: generateLinearData(12),
enableArea: true,
},
{
id: 'forecast',
label: 'Forecast',
color: 'purple',
data: generateLinearData(12),
enableArea: false,
},
{
id: 'target',
@@ -385,18 +298,13 @@ export const MultiSeriesMixed: Story = {
...dataPoint,
y: 75,
})),
enableArea: false,
},
],
enableArea: false,
showLegend: true,
showGrid: true,
enablePoints: false,
curve: 'monotoneX',
xAxisLabel: 'Month',
yAxisLabel: 'Value',
displayType: 'shortNumber',
enableSlices: 'x',
},
};
@@ -451,11 +359,8 @@ export const OverlappingGradientBlend: Story = {
],
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: false,
curve: 'monotoneX',
xAxisLabel: 'Time',
yAxisLabel: 'Value',
displayType: 'number',
@@ -496,11 +401,8 @@ export const HighContrastOverlap: Story = {
],
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: true,
curve: 'natural',
xAxisLabel: 'Day',
yAxisLabel: 'Score',
displayType: 'number',
@@ -529,8 +431,6 @@ export const CurveComparison: Story = {
],
showLegend: true,
showGrid: true,
enablePoints: true,
curve: 'linear',
xAxisLabel: 'X Axis',
yAxisLabel: 'Y Axis',
displayType: 'number',
@@ -584,8 +484,6 @@ export const StepInterpolations: Story = {
],
showLegend: true,
showGrid: true,
enablePoints: true,
curve: 'step',
xAxisLabel: 'Time',
yAxisLabel: 'Value',
displayType: 'number',
@@ -626,11 +524,8 @@ export const NaturalVsMonotone: Story = {
],
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: true,
curve: 'natural',
xAxisLabel: 'Sample',
yAxisLabel: 'Measurement',
displayType: 'number',
@@ -684,9 +579,6 @@ export const SliceTooltipDemo: Story = {
],
showLegend: true,
showGrid: true,
enablePoints: false,
enableSlices: 'x',
xScale: { type: 'point' },
xAxisLabel: 'Month',
yAxisLabel: 'Amount ($)',
displayType: 'shortNumber',
@@ -741,9 +633,6 @@ export const PointTooltipDemo: Story = {
],
showLegend: true,
showGrid: true,
enablePoints: true,
enableSlices: 'x',
xScale: { type: 'point' },
xAxisLabel: 'Month',
yAxisLabel: 'Amount ($)',
displayType: 'shortNumber',
@@ -751,62 +640,6 @@ export const PointTooltipDemo: Story = {
},
};
export const IntenseOverlapRGB: Story = {
render: renderChart,
args: {
id: 'line-chart-rgb',
data: [
{
id: 'red',
label: 'Red Channel',
color: 'red',
data: [
{ x: 0, y: 70 },
{ x: 1, y: 85 },
{ x: 2, y: 75 },
{ x: 3, y: 90 },
{ x: 4, y: 80 },
{ x: 5, y: 85 },
],
},
{
id: 'green',
label: 'Green Channel',
color: 'turquoise',
data: [
{ x: 0, y: 65 },
{ x: 1, y: 75 },
{ x: 2, y: 85 },
{ x: 3, y: 80 },
{ x: 4, y: 75 },
{ x: 5, y: 70 },
],
},
{
id: 'blue',
label: 'Blue Channel',
color: 'blue',
data: [
{ x: 0, y: 60 },
{ x: 1, y: 70 },
{ x: 2, y: 80 },
{ x: 3, y: 75 },
{ x: 4, y: 85 },
{ x: 5, y: 80 },
],
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: false,
curve: 'monotoneX',
xAxisLabel: 'Position',
yAxisLabel: 'Intensity',
displayType: 'percentage',
},
};
export const Catalog: Story = {
render: renderChart,
args: {
@@ -827,8 +660,6 @@ export const Catalog: Story = {
],
showLegend: true,
showGrid: true,
enablePoints: false,
enableArea: true,
},
decorators: [CatalogDecorator],
parameters: {
@@ -0,0 +1 @@
export const CHART_LEGEND_ITEM_THRESHOLD = 10;
@@ -1,16 +1,14 @@
import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip';
import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
import { getBarChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData';
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
import { getTooltipReferenceFromBarChartElementAnchor } from '@/page-layout/widgets/graph/utils/getTooltipReferenceFromBarChartElementAnchor';
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
import { useMemo } from 'react';
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
type GraphBarChartTooltipProps = {
datum: ComputedDatum<BarDatum>;
anchorElement: Element;
containerId: string;
enrichedKeys: BarChartEnrichedKey[];
data: BarChartDataItem[];
@@ -23,8 +21,6 @@ type GraphBarChartTooltipProps = {
};
export const GraphBarChartTooltip = ({
datum,
anchorElement,
containerId,
enrichedKeys,
data,
@@ -35,38 +31,38 @@ export const GraphBarChartTooltip = ({
onMouseEnter,
onMouseLeave,
}: GraphBarChartTooltipProps) => {
const tooltipData = useMemo(
() =>
getBarChartTooltipData({
datum,
const tooltipState = useRecoilComponentValue(
graphWidgetBarTooltipComponentState,
);
const tooltipData = !isDefined(tooltipState)
? null
: getBarChartTooltipData({
datum: tooltipState.datum,
enrichedKeys,
data,
indexBy,
formatOptions,
enableGroupTooltip,
layout,
}),
[
datum,
enrichedKeys,
data,
indexBy,
formatOptions,
enableGroupTooltip,
layout,
],
);
});
const { reference, boundary } = useMemo(() => {
let reference = null;
let boundary = null;
if (isDefined(tooltipState)) {
try {
return getTooltipReferenceFromBarChartElementAnchor(
anchorElement,
const positioning = getTooltipReferenceFromBarChartElementAnchor(
tooltipState.anchorElement,
containerId,
);
reference = positioning.reference;
boundary = positioning.boundary;
} catch {
return { reference: null, boundary: null };
reference = null;
boundary = null;
}
}, [anchorElement, containerId]);
}
if (
!isDefined(tooltipData) ||
@@ -9,8 +9,8 @@ import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChar
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { calculateBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange';
import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange';
import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys';
import { getBarChartAxisConfigs } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs';
import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor';
import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins';
@@ -33,7 +33,9 @@ import { useCallback, useMemo, useRef, useState, type MouseEvent } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import { BAR_CHART_LEGEND_ITEM_THRESHOLD } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartLegendItemThreshold';
import { CHART_LEGEND_ITEM_THRESHOLD } from '@/page-layout/widgets/graph/constants/ChartLegendItemThreshold';
import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
type GraphWidgetBarChartProps = {
data: BarChartDataItem[];
@@ -94,10 +96,9 @@ export const GraphWidgetBarChart = ({
const [chartHeight, setChartHeight] = useState<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
const [activeBarTooltip, setActiveBarTooltip] = useState<{
datum: ComputedDatum<BarChartDataItem>;
anchorElement: Element;
} | null>(null);
const setActiveBarTooltip = useSetRecoilComponentState(
graphWidgetBarTooltipComponentState,
);
const formatOptions: GraphValueFormatOptions = {
displayType,
@@ -120,7 +121,7 @@ export const GraphWidgetBarChart = ({
const hasClickableItems = data.some((item) => isDefined(item.to));
const hideTooltip = useCallback(() => setActiveBarTooltip(null), []);
const hideTooltip = () => setActiveBarTooltip(null);
const debouncedHideTooltip = useDebouncedCallback(hideTooltip, 300);
const handleTooltipMouseEnter = () => {
@@ -140,14 +141,14 @@ export const GraphWidgetBarChart = ({
anchorElement: event.currentTarget,
});
},
[debouncedHideTooltip],
[debouncedHideTooltip, setActiveBarTooltip],
);
const handleBarLeave = useCallback(() => {
debouncedHideTooltip();
}, [debouncedHideTooltip]);
const areThereTooManyKeys = keys.length > BAR_CHART_LEGEND_ITEM_THRESHOLD;
const areThereTooManyKeys = keys.length > CHART_LEGEND_ITEM_THRESHOLD;
const shouldShowLegend = showLegend && !areThereTooManyKeys;
@@ -199,14 +200,14 @@ export const GraphWidgetBarChart = ({
</>
);
const calculatedRange =
const calculatedValueRange =
groupMode === 'stacked'
? calculateStackedBarChartValueRange(data, keys)
: calculateBarChartValueRange(data, keys);
const effectiveMin = rangeMin ?? calculatedRange.min;
const effectiveMax = rangeMax ?? calculatedRange.max;
: calculateValueRangeFromBarChartKeys(data, keys);
const effectiveMinimumValue = rangeMin ?? calculatedValueRange.minimum;
const effectiveMaximumValue = rangeMax ?? calculatedValueRange.maximum;
const hasNegativeValues = calculatedRange.min < 0;
const hasNegativeValues = calculatedValueRange.minimum < 0;
const zeroMarker = hasNegativeValues
? [
{
@@ -222,8 +223,6 @@ export const GraphWidgetBarChart = ({
const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout });
const shouldShowBarChartTooltip = isDefined(activeBarTooltip);
return (
<StyledContainer id={id}>
<GraphWidgetChartContainer
@@ -249,8 +248,8 @@ export const GraphWidgetBarChart = ({
layout={layout}
valueScale={{
type: 'linear',
min: effectiveMin,
max: effectiveMax,
min: effectiveMinimumValue,
max: effectiveMaximumValue,
clamp: true,
}}
indexScale={{ type: 'band', round: true }}
@@ -286,21 +285,17 @@ export const GraphWidgetBarChart = ({
/>
</GraphWidgetChartContainer>
{shouldShowBarChartTooltip && (
<GraphBarChartTooltip
datum={activeBarTooltip.datum}
anchorElement={activeBarTooltip.anchorElement}
containerId={id}
enrichedKeys={enrichedKeys}
data={data}
indexBy={indexBy}
formatOptions={formatOptions}
enableGroupTooltip={groupMode === 'stacked'}
layout={layout}
onMouseEnter={handleTooltipMouseEnter}
onMouseLeave={handleTooltipMouseLeave}
/>
)}
<GraphBarChartTooltip
containerId={id}
enrichedKeys={enrichedKeys}
data={data}
indexBy={indexBy}
formatOptions={formatOptions}
enableGroupTooltip={groupMode === 'stacked'}
layout={layout}
onMouseEnter={handleTooltipMouseEnter}
onMouseLeave={handleTooltipMouseLeave}
/>
<GraphWidgetLegend
show={shouldShowLegend}
items={enrichedKeys.map((item) => {
@@ -1,8 +1,9 @@
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { GraphWidgetBarChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartHasTooManyGroupsEffect';
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData';
import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode';
import { lazy, Suspense, useMemo } from 'react';
import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey';
import { lazy, Suspense } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type BarChartConfiguration,
@@ -48,14 +49,10 @@ export const GraphWidgetBarChartRenderer = ({
hasGroupByOnSecondaryAxis,
);
const filterStateKey = useMemo(
() =>
`${configuration.rangeMin ?? ''}-${configuration.rangeMax ?? ''}-${configuration.omitNullValues ?? ''}`,
[
configuration.rangeMin,
configuration.rangeMax,
configuration.omitNullValues,
],
const chartFilterKey = generateChartAggregateFilterKey(
configuration.rangeMin,
configuration.rangeMax,
configuration.omitNullValues,
);
if (loading) {
@@ -64,12 +61,12 @@ export const GraphWidgetBarChartRenderer = ({
return (
<>
<GraphWidgetBarChartHasTooManyGroupsEffect
<GraphWidgetChartHasTooManyGroupsEffect
hasTooManyGroups={hasTooManyGroups}
/>
<Suspense fallback={<ChartSkeletonLoader />}>
<GraphWidgetBarChart
key={filterStateKey}
key={chartFilterKey}
data={data}
series={series}
indexBy={indexBy}
@@ -1 +0,0 @@
export const BAR_CHART_LEGEND_ITEM_THRESHOLD = 10;
@@ -0,0 +1,13 @@
import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type ComputedDatum } from '@nivo/bar';
export const graphWidgetBarTooltipComponentState = createComponentState<{
datum: ComputedDatum<BarChartDataItem>;
anchorElement: Element;
} | null>({
key: 'graphWidgetBarTooltipComponentState',
defaultValue: null,
componentInstanceContext: GraphWidgetComponentInstanceContext,
});
@@ -1,8 +1,8 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { calculateBarChartValueRange } from '../calculateBarChartValueRange';
import { calculateValueRangeFromBarChartKeys } from '../calculateValueRangeFromBarChartKeys';
describe('calculateBarChartValueRange (essential cases)', () => {
it('returns min=0 and max=highest value for all positive values', () => {
describe('calculateValueRangeFromBarChartKeys (essential cases)', () => {
it('returns minimum=0 and maximum=highest value for all positive values', () => {
const data: BarChartDataItem[] = [
{ category: 'A', v1: 10, v2: 20 },
{ category: 'B', v1: 30, v2: 15 },
@@ -10,13 +10,13 @@ describe('calculateBarChartValueRange (essential cases)', () => {
];
const keys = ['v1', 'v2'];
expect(calculateBarChartValueRange(data, keys)).toEqual({
min: 0,
max: 40,
expect(calculateValueRangeFromBarChartKeys(data, keys)).toEqual({
minimum: 0,
maximum: 40,
});
});
it('returns min=lowest and max=0 for all negative values', () => {
it('returns minimum=lowest and maximum=0 for all negative values', () => {
const data: BarChartDataItem[] = [
{ category: 'A', v1: -10, v2: -20 },
{ category: 'B', v1: -30, v2: -15 },
@@ -24,13 +24,13 @@ describe('calculateBarChartValueRange (essential cases)', () => {
];
const keys = ['v1', 'v2'];
expect(calculateBarChartValueRange(data, keys)).toEqual({
min: -40,
max: 0,
expect(calculateValueRangeFromBarChartKeys(data, keys)).toEqual({
minimum: -40,
maximum: 0,
});
});
it('includes zero and spans min/max when values cross zero', () => {
it('includes zero and spans minimum/maximum when values cross zero', () => {
const data: BarChartDataItem[] = [
{ category: 'A', v1: -20, v2: 30 },
{ category: 'B', v1: 15, v2: -10 },
@@ -38,17 +38,22 @@ describe('calculateBarChartValueRange (essential cases)', () => {
];
const keys = ['v1', 'v2'];
expect(calculateBarChartValueRange(data, keys)).toEqual({
min: -20,
max: 30,
expect(calculateValueRangeFromBarChartKeys(data, keys)).toEqual({
minimum: -20,
maximum: 30,
});
});
it('handles empty data and empty keys', () => {
expect(calculateBarChartValueRange([], ['v'])).toEqual({ min: 0, max: 0 });
expect(calculateBarChartValueRange([{ cat: 'A', v: 10 }], [])).toEqual({
min: 0,
max: 0,
expect(calculateValueRangeFromBarChartKeys([], ['v'])).toEqual({
minimum: 0,
maximum: 0,
});
expect(
calculateValueRangeFromBarChartKeys([{ cat: 'A', v: 10 }], []),
).toEqual({
minimum: 0,
maximum: 0,
});
});
@@ -60,9 +65,9 @@ describe('calculateBarChartValueRange (essential cases)', () => {
];
const keys = ['v1', 'v2'];
expect(calculateBarChartValueRange(data, keys)).toEqual({
min: 0,
max: 30,
expect(calculateValueRangeFromBarChartKeys(data, keys)).toEqual({
minimum: 0,
maximum: 30,
});
});
});
@@ -2,7 +2,7 @@ import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBa
import { calculateStackedBarChartValueRange } from '../calculateStackedBarChartValueRange';
describe('calculateStackedBarChartValueRange (essential cases)', () => {
it('returns min=0 and max=largest positive stack', () => {
it('returns minimum=0 and maximum=largest positive stack', () => {
const data: BarChartDataItem[] = [
{ cat: 'A', v1: 100, v2: 200, v3: 50 },
{ cat: 'B', v1: 150, v2: 25, v3: 75 },
@@ -11,12 +11,12 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => {
const keys = ['v1', 'v2', 'v3'];
expect(calculateStackedBarChartValueRange(data, keys)).toEqual({
min: 0,
max: 350,
minimum: 0,
maximum: 350,
});
});
it('returns min=most negative stack and max=0 for all negative values', () => {
it('returns minimum=most negative stack and maximum=0 for all negative values', () => {
const data: BarChartDataItem[] = [
{ cat: 'A', v1: -100, v2: -200, v3: 0 },
{ cat: 'B', v1: -50, v2: -25, v3: -75 },
@@ -24,8 +24,8 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => {
const keys = ['v1', 'v2', 'v3'];
expect(calculateStackedBarChartValueRange(data, keys)).toEqual({
min: -300,
max: 0,
minimum: -300,
maximum: 0,
});
});
@@ -38,19 +38,19 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => {
const keys = ['v1', 'v2', 'v3'];
expect(calculateStackedBarChartValueRange(data, keys)).toEqual({
min: -110,
max: 120,
minimum: -110,
maximum: 120,
});
});
it('handles empty data and empty keys', () => {
expect(calculateStackedBarChartValueRange([], ['v1'])).toEqual({
min: 0,
max: 0,
minimum: 0,
maximum: 0,
});
expect(
calculateStackedBarChartValueRange([{ cat: 'A', v1: 10 }], []),
).toEqual({ min: 0, max: 0 });
).toEqual({ minimum: 0, maximum: 0 });
});
it('ignores missing keys and NaN values', () => {
@@ -62,8 +62,8 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => {
const keys = ['v1', 'v2'];
expect(calculateStackedBarChartValueRange(data, keys)).toEqual({
min: 0,
max: 30,
minimum: 0,
maximum: 30,
});
});
});
@@ -1,37 +0,0 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
type ValueRange = {
min: number;
max: number;
};
export const calculateBarChartValueRange = (
data: BarChartDataItem[],
keys: string[],
): ValueRange => {
let min = 0;
let max = 0;
for (const item of data) {
for (const key of keys) {
const value = Number(item[key] ?? 0);
if (!isNaN(value)) {
if (value < min) {
min = value;
}
if (value > max) {
max = value;
}
}
}
}
if (min > 0) {
min = 0;
}
if (max < 0) {
max = 0;
}
return { min, max };
};
@@ -1,46 +1,32 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
type ValueRange = {
min: number;
max: number;
};
import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues';
import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange';
export const calculateStackedBarChartValueRange = (
data: BarChartDataItem[],
keys: string[],
): ValueRange => {
let min = 0;
let max = 0;
): ChartValueRange => {
const stackedValues: number[] = [];
for (const item of data) {
let positiveSum = 0;
let negativeSum = 0;
let positiveSummation = 0;
let negativeSummation = 0;
for (const key of keys) {
const value = Number(item[key] ?? 0);
if (!Number.isNaN(value)) {
if (value >= 0) {
positiveSum += value;
} else {
negativeSum += value;
}
if (Number.isNaN(value)) {
continue;
}
if (value >= 0) {
positiveSummation += value;
} else {
negativeSummation += value;
}
}
if (positiveSum > max) {
max = positiveSum;
}
if (negativeSum < min) {
min = negativeSum;
}
stackedValues.push(positiveSummation, negativeSummation);
}
if (min > 0) {
min = 0;
}
if (max < 0) {
max = 0;
}
return { min, max };
return calculateValueRangeFromValues(stackedValues);
};
@@ -0,0 +1,19 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues';
import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange';
export const calculateValueRangeFromBarChartKeys = (
data: BarChartDataItem[],
keys: string[],
): ChartValueRange => {
const values: number[] = [];
for (const item of data) {
for (const key of keys) {
const value = Number(item[key] ?? 0);
values.push(value);
}
}
return calculateValueRangeFromValues(values);
};
@@ -3,6 +3,7 @@ import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBa
import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout';
import { computeMinHeightPerTick } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeMinHeightPerTick';
import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins';
import { computeChartCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeChartCategoryTickValues';
export const computeBarChartCategoryTickValues = ({
axisSize,
@@ -21,7 +22,11 @@ export const computeBarChartCategoryTickValues = ({
xAxisLabel?: string;
yAxisLabel?: string;
}): (string | number)[] => {
if (axisSize === 0 || data.length === 0) return [];
if (axisSize === 0 || data.length === 0) {
return [];
}
const values = data.map((item) => item[indexBy] as string | number);
const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout });
@@ -32,22 +37,14 @@ export const computeBarChartCategoryTickValues = ({
const availableAxisSize = axisSize - totalMargins;
const numberOfTicks = Math.floor(
availableAxisSize /
(layout === BarChartLayout.VERTICAL
? BAR_CHART_MINIMUM_WIDTH_PER_TICK
: computeMinHeightPerTick({ axisFontSize })),
);
const minimumSizePerTick =
layout === BarChartLayout.VERTICAL
? BAR_CHART_MINIMUM_WIDTH_PER_TICK
: computeMinHeightPerTick({ axisFontSize });
if (numberOfTicks <= 0) return [];
if (numberOfTicks === 1) return [data[0][indexBy] as string | number];
if (numberOfTicks >= data.length)
return data.map((item) => item[indexBy] as string | number);
const step = (data.length - 1) / (numberOfTicks - 1);
return Array.from({ length: numberOfTicks }, (_, i) => {
const index = Math.min(Math.round(i * step), data.length - 1);
return data[index][indexBy] as string | number;
return computeChartCategoryTickValues({
availableSize: availableAxisSize,
minimumSizePerTick,
values,
});
};
@@ -5,6 +5,8 @@ import { LINE_CHART_CROSSHAIR_TRANSITION_DAMPING } from '@/page-layout/widgets/g
import { LINE_CHART_CROSSHAIR_TRANSITION_STIFFNESS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionStiffness';
import { LINE_CHART_MARGIN_LEFT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft';
import { LINE_CHART_MARGIN_TOP } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginTop';
import { graphWidgetLineCrosshairXComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineCrosshairXComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import { type LineSeries, type Point } from '@nivo/line';
import { motion } from 'framer-motion';
@@ -28,7 +30,6 @@ type CustomCrosshairLayerProps = {
innerHeight: number;
innerWidth: number;
onSliceHover: (data: SliceHoverData) => void;
crosshairX: number | null;
onRectLeave: (relatedTarget: EventTarget | null) => void;
};
@@ -37,10 +38,12 @@ export const CustomCrosshairLayer = ({
innerHeight,
innerWidth,
onSliceHover,
crosshairX,
onRectLeave,
}: CustomCrosshairLayerProps) => {
const theme = useTheme();
const crosshairX = useRecoilComponentValue(
graphWidgetLineCrosshairXComponentState,
);
const slices = useMemo(() => {
const sliceMap = new Map<string, Point<LineSeries>[]>();
@@ -1,60 +1,61 @@
import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip';
import { graphWidgetLineTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineTooltipComponentState';
import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries';
import { getLineChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartTooltipData';
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
import { getTooltipReferenceFromLineChartPointAnchor } from '@/page-layout/widgets/graph/utils/getTooltipReferenceFromLineChartPointAnchor';
import { type LineSeries, type SliceTooltipProps } from '@nivo/line';
import { useMemo } from 'react';
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
type GraphLineChartTooltipProps = {
slice: SliceTooltipProps<LineSeries>['slice'];
offsetLeft: number;
offsetTop: number;
containerId: string;
enrichedSeries: LineChartEnrichedSeries[];
formatOptions: GraphValueFormatOptions;
highlightedSeriesId?: string;
linkTo?: string;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
};
export const GraphLineChartTooltip = ({
slice,
offsetLeft,
offsetTop,
containerId,
enrichedSeries,
formatOptions,
highlightedSeriesId,
linkTo,
onMouseEnter,
onMouseLeave,
}: GraphLineChartTooltipProps) => {
const tooltipData = useMemo(
() =>
getLineChartTooltipData({
slice,
const tooltipState = useRecoilComponentValue(
graphWidgetLineTooltipComponentState,
);
const tooltipData = !isDefined(tooltipState)
? null
: getLineChartTooltipData({
slice: tooltipState.slice,
enrichedSeries,
formatOptions,
}),
[slice, enrichedSeries, formatOptions],
);
});
const { reference, boundary } = useMemo(() => {
let reference = null;
let boundary = null;
if (isDefined(tooltipState)) {
try {
return getTooltipReferenceFromLineChartPointAnchor(
const positioning = getTooltipReferenceFromLineChartPointAnchor(
containerId,
offsetLeft,
offsetTop,
tooltipState.offsetLeft,
tooltipState.offsetTop,
);
reference = positioning.reference;
boundary = positioning.boundary;
} catch {
return { reference: null, boundary: null };
reference = null;
boundary = null;
}
}, [containerId, offsetLeft, offsetTop]);
}
if (!isDefined(reference) || !isDefined(boundary)) {
if (
!isDefined(tooltipData) ||
!isDefined(reference) ||
!isDefined(boundary)
) {
return null;
}
@@ -64,8 +65,8 @@ export const GraphLineChartTooltip = ({
boundary={boundary}
items={tooltipData.items}
indexLabel={tooltipData.indexLabel}
highlightedKey={highlightedSeriesId}
linkTo={linkTo}
highlightedKey={tooltipState?.highlightedSeriesId}
linkTo={tooltipState?.linkTo}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
@@ -1,5 +1,6 @@
import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer';
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
import { CHART_LEGEND_ITEM_THRESHOLD } from '@/page-layout/widgets/graph/constants/ChartLegendItemThreshold';
import {
CustomCrosshairLayer,
type SliceHoverData,
@@ -11,64 +12,47 @@ import { LINE_CHART_MARGIN_RIGHT } from '@/page-layout/widgets/graph/graphWidget
import { LINE_CHART_MARGIN_TOP } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginTop';
import { useLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData';
import { useLineChartTheme } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTheme';
import { graphWidgetLineCrosshairXComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineCrosshairXComponentState';
import { graphWidgetLineTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineTooltipComponentState';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
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 { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
ResponsiveLine,
type LineCustomSvgLayerProps,
type LineSeries,
type SliceTooltipProps,
} from '@nivo/line';
import { type ScaleLinearSpec, type ScaleSpec } from '@nivo/scales';
import { useCallback, useId, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useCallback, useId, useRef, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
type CrosshairLayerProps = LineCustomSvgLayerProps<LineSeries>;
type GraphWidgetLineChartProps = {
data: LineChartSeries[];
showLegend?: boolean;
showGrid?: boolean;
enablePoints?: boolean;
enablePointLabel?: boolean;
xAxisLabel?: string;
enableArea?: boolean;
yAxisLabel?: string;
id: string;
enableArea?: boolean;
stackedArea?: boolean;
curve?:
| 'linear'
| 'monotoneX'
| 'step'
| 'stepBefore'
| 'stepAfter'
| 'natural';
lineWidth?: number;
enableSlices?: 'x' | 'y';
xScale?: ScaleSpec;
yScale?: ScaleSpec;
rangeMin?: number;
rangeMax?: number;
omitNullValues?: boolean;
groupMode?: 'stacked';
} & GraphValueFormatOptions;
const getYScaleWithStacking = (
yScale: ScaleSpec | undefined,
stackedArea: boolean | undefined,
): ScaleSpec => {
if (!yScale || yScale.type === 'linear') {
const linearScale: ScaleLinearSpec = {
min: 0,
max: 'auto',
...yScale,
type: 'linear',
stacked: stackedArea,
};
return linearScale;
}
return yScale;
};
const StyledContainer = styled.div`
align-items: center;
display: flex;
@@ -82,18 +66,16 @@ export const GraphWidgetLineChart = ({
data,
showLegend = true,
showGrid = true,
enablePoints = false,
enableArea = true,
enablePointLabel = false,
xAxisLabel,
yAxisLabel,
id,
enableArea = false,
stackedArea = false,
curve = 'monotoneX',
lineWidth = 2,
enableSlices = 'x',
xScale = { type: 'linear' },
yScale = { type: 'linear', min: 0, max: 'auto' },
rangeMin,
rangeMax,
omitNullValues: _omitNullValues = false,
displayType,
groupMode,
decimals,
prefix,
suffix,
@@ -103,6 +85,8 @@ export const GraphWidgetLineChart = ({
const instanceId = useId();
const colorRegistry = createGraphColorRegistry(theme);
const chartTheme = useLineChartTheme();
const containerRef = useRef<HTMLDivElement>(null);
const [chartWidth, setChartWidth] = useState(0);
const formatOptions: GraphValueFormatOptions = {
displayType,
@@ -112,6 +96,10 @@ export const GraphWidgetLineChart = ({
customFormatter,
};
const calculatedValueRange = calculateValueRangeFromLineChartSeries(data);
const effectiveMinimumValue = rangeMin ?? calculatedValueRange.minimum;
const effectiveMaximumValue = rangeMax ?? calculatedValueRange.maximum;
const {
dataMap,
enrichedSeries,
@@ -130,19 +118,18 @@ export const GraphWidgetLineChart = ({
theme,
});
const [activeLineTooltip, setActiveLineTooltip] = useState<{
slice: SliceTooltipProps<LineSeries>['slice'];
offsetLeft: number;
offsetTop: number;
highlightedSeriesId: string;
linkTo: string | undefined;
} | null>(null);
const [crosshairX, setCrosshairX] = useState<number | null>(null);
const setActiveLineTooltip = useSetRecoilComponentState(
graphWidgetLineTooltipComponentState,
);
const setCrosshairX = useSetRecoilComponentState(
graphWidgetLineCrosshairXComponentState,
);
const hideTooltip = useCallback(() => {
setActiveLineTooltip(null);
setCrosshairX(null);
}, []);
}, [setActiveLineTooltip, setCrosshairX]);
const debouncedHideTooltip = useDebouncedCallback(hideTooltip, 300);
@@ -182,20 +169,42 @@ export const GraphWidgetLineChart = ({
linkTo,
});
},
[dataMap, debouncedHideTooltip],
[dataMap, debouncedHideTooltip, setActiveLineTooltip, setCrosshairX],
);
const axisBottomConfig = getLineChartAxisBottomConfig(xAxisLabel);
const CrosshairLayer = (layerProps: CrosshairLayerProps) => (
<CustomCrosshairLayer
key="custom-crosshair-layer"
points={layerProps.points}
innerHeight={layerProps.innerHeight}
innerWidth={layerProps.innerWidth}
onSliceHover={handleSliceHover}
onRectLeave={() => debouncedHideTooltip()}
/>
);
const axisBottomConfig = getLineChartAxisBottomConfig(
xAxisLabel,
chartWidth,
data,
);
const axisLeftConfig = getLineChartAxisLeftConfig(yAxisLabel, formatOptions);
const shouldShowLineChartTooltip = isDefined(activeLineTooltip);
const areThereTooManySeries = data.length > CHART_LEGEND_ITEM_THRESHOLD;
const shouldShowLegend = showLegend && !areThereTooManySeries;
return (
<StyledContainer id={id}>
<GraphWidgetChartContainer
$isClickable={hasClickableItems}
onMouseLeave={() => debouncedHideTooltip()}
ref={containerRef}
>
<NodeDimensionEffect
elementRef={containerRef}
onDimensionChange={({ width }) => {
setChartWidth(width);
}}
/>
<ResponsiveLine
data={nivoData}
margin={{
@@ -204,15 +213,25 @@ export const GraphWidgetLineChart = ({
bottom: LINE_CHART_MARGIN_BOTTOM,
left: LINE_CHART_MARGIN_LEFT,
}}
xScale={xScale}
yScale={getYScaleWithStacking(yScale, stackedArea)}
curve={curve}
lineWidth={lineWidth}
xScale={{ type: 'point' }}
yScale={{
type: 'linear',
min: effectiveMinimumValue,
max: effectiveMaximumValue,
stacked: groupMode === 'stacked',
clamp: true,
}}
curve={'monotoneX'}
lineWidth={2}
enableArea={enableArea}
areaBaselineValue={0}
enablePoints={enablePoints}
pointSize={6}
enablePoints={true}
pointSize={0}
enablePointLabel={enablePointLabel}
pointBorderWidth={0}
pointLabel={(point) =>
formatGraphValue(Number(point.data.y), formatOptions)
}
colors={colors}
areaBlendMode={'normal'}
defs={defs}
@@ -223,7 +242,7 @@ export const GraphWidgetLineChart = ({
axisLeft={axisLeftConfig}
enableGridX={showGrid}
enableGridY={showGrid}
enableSlices={enableSlices}
enableSlices={'x'}
sliceTooltip={() => null}
tooltip={() => null}
layers={[
@@ -232,17 +251,7 @@ export const GraphWidgetLineChart = ({
'axes',
'areas',
'lines',
(layerProps) => (
<CustomCrosshairLayer
key="custom-crosshair-layer"
points={layerProps.points}
innerHeight={layerProps.innerHeight}
innerWidth={layerProps.innerWidth}
onSliceHover={handleSliceHover}
crosshairX={crosshairX}
onRectLeave={() => debouncedHideTooltip()}
/>
),
CrosshairLayer,
'points',
'legends',
]}
@@ -251,21 +260,14 @@ export const GraphWidgetLineChart = ({
theme={chartTheme}
/>
</GraphWidgetChartContainer>
{shouldShowLineChartTooltip && (
<GraphLineChartTooltip
slice={activeLineTooltip.slice}
offsetLeft={activeLineTooltip.offsetLeft}
offsetTop={activeLineTooltip.offsetTop}
containerId={id}
enrichedSeries={enrichedSeries}
formatOptions={formatOptions}
highlightedSeriesId={activeLineTooltip.highlightedSeriesId}
linkTo={activeLineTooltip.linkTo}
onMouseEnter={handleTooltipMouseEnter}
onMouseLeave={handleTooltipMouseLeave}
/>
)}
<GraphWidgetLegend show={showLegend} items={legendItems} />
<GraphLineChartTooltip
containerId={id}
enrichedSeries={enrichedSeries}
formatOptions={formatOptions}
onMouseEnter={handleTooltipMouseEnter}
onMouseLeave={handleTooltipMouseLeave}
/>
<GraphWidgetLegend show={shouldShowLegend} items={legendItems} />
</StyledContainer>
);
};
@@ -0,0 +1,80 @@
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
import { LINE_CHART_IS_STACKED_DEFAULT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartIsStackedDefault';
import { useGraphLineChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData';
import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey';
import { lazy, Suspense } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type LineChartConfiguration,
type PageLayoutWidget,
} from '~/generated/graphql';
const GraphWidgetLineChart = lazy(() =>
import(
'@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart'
).then((module) => ({
default: module.GraphWidgetLineChart,
})),
);
export const GraphWidgetLineChartRenderer = ({
widget,
}: {
widget: PageLayoutWidget;
}) => {
const {
series,
xAxisLabel,
yAxisLabel,
showDataLabels,
hasTooManyGroups,
loading,
} = useGraphLineChartWidgetData({
objectMetadataItemId: widget.objectMetadataId,
configuration: widget.configuration as LineChartConfiguration,
});
const configuration = widget.configuration as LineChartConfiguration;
const hasGroupByOnSecondaryAxis = isDefined(
configuration.secondaryAxisGroupByFieldMetadataId,
);
const groupMode =
hasGroupByOnSecondaryAxis &&
(configuration.isStacked ?? LINE_CHART_IS_STACKED_DEFAULT)
? 'stacked'
: undefined;
const chartFilterKey = generateChartAggregateFilterKey(
configuration.rangeMin,
configuration.rangeMax,
configuration.omitNullValues,
);
if (loading) {
return <ChartSkeletonLoader />;
}
return (
<Suspense fallback={<ChartSkeletonLoader />}>
<GraphWidgetChartHasTooManyGroupsEffect
hasTooManyGroups={hasTooManyGroups}
/>
<GraphWidgetLineChart
key={chartFilterKey}
id={widget.id}
data={series}
xAxisLabel={xAxisLabel}
yAxisLabel={yAxisLabel}
enablePointLabel={showDataLabels}
rangeMin={configuration.rangeMin ?? undefined}
rangeMax={configuration.rangeMax ?? undefined}
omitNullValues={configuration.omitNullValues ?? false}
groupMode={groupMode}
displayType="shortNumber"
/>
</Suspense>
);
};
@@ -1 +1 @@
export const LINE_CHART_CROSSHAIR_TRANSITION_DAMPING = 20;
export const LINE_CHART_CROSSHAIR_TRANSITION_DAMPING = 30;
@@ -0,0 +1 @@
export const LINE_CHART_IS_STACKED_DEFAULT = true;
@@ -0,0 +1 @@
export const LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS = 50;
@@ -0,0 +1,57 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery';
import { transformGroupByDataToLineChartData } from '@/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData';
import { useMemo } from 'react';
import { type LineChartConfiguration } from '~/generated/graphql';
type UseGraphLineChartWidgetDataProps = {
objectMetadataItemId: string;
configuration: LineChartConfiguration;
};
type UseGraphLineChartWidgetDataResult = {
series: LineChartSeries[];
xAxisLabel?: string;
yAxisLabel?: string;
showDataLabels: boolean;
hasTooManyGroups: boolean;
loading: boolean;
error?: Error;
};
export const useGraphLineChartWidgetData = ({
objectMetadataItemId,
configuration,
}: UseGraphLineChartWidgetDataProps): UseGraphLineChartWidgetDataResult => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const {
data: groupByData,
loading,
error,
aggregateOperation,
} = useGraphWidgetGroupByQuery({
objectMetadataItemId,
configuration,
});
const transformedData = useMemo(
() =>
transformGroupByDataToLineChartData({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation,
}),
[groupByData, objectMetadataItem, configuration, aggregateOperation],
);
return {
...transformedData,
loading,
error,
};
};
@@ -0,0 +1,10 @@
import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const graphWidgetLineCrosshairXComponentState = createComponentState<
number | null
>({
key: 'graphWidgetLineCrosshairXComponentState',
defaultValue: null,
componentInstanceContext: GraphWidgetComponentInstanceContext,
});
@@ -0,0 +1,15 @@
import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type LineSeries, type SliceTooltipProps } from '@nivo/line';
export const graphWidgetLineTooltipComponentState = createComponentState<{
slice: SliceTooltipProps<LineSeries>['slice'];
offsetLeft: number;
offsetTop: number;
highlightedSeriesId: string;
linkTo: string | undefined;
} | null>({
key: 'graphWidgetLineTooltipComponentState',
defaultValue: null,
componentInstanceContext: GraphWidgetComponentInstanceContext,
});
@@ -0,0 +1,18 @@
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues';
import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange';
export const calculateValueRangeFromLineChartSeries = (
data: LineChartSeries[],
): ChartValueRange => {
const values: number[] = [];
for (const series of data) {
for (const point of series.data) {
const value = Number(point.y ?? 0);
values.push(value);
}
}
return calculateValueRangeFromValues(values);
};
@@ -0,0 +1,34 @@
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { computeChartCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeChartCategoryTickValues';
const LINE_CHART_MARGIN_LEFT = 70;
const LINE_CHART_MARGIN_RIGHT = 20;
const LINE_CHART_MINIMUM_WIDTH_PER_TICK = 100;
export const computeLineChartCategoryTickValues = ({
width,
data,
}: {
width: number;
data: LineChartSeries[];
}): (string | number)[] => {
if (data.length === 0 || data[0].data.length === 0) {
return [];
}
const values = data[0].data.map((point) => {
const value = point.x;
return typeof value === 'number' || typeof value === 'string'
? value
: String(value);
});
const availableWidth =
width - (LINE_CHART_MARGIN_LEFT + LINE_CHART_MARGIN_RIGHT);
return computeChartCategoryTickValues({
availableSize: availableWidth,
minimumSizePerTick: LINE_CHART_MINIMUM_WIDTH_PER_TICK,
values,
});
};
@@ -1,8 +1,23 @@
export const getLineChartAxisBottomConfig = (xAxisLabel?: string) => ({
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
});
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { computeLineChartCategoryTickValues } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues';
export const getLineChartAxisBottomConfig = (
xAxisLabel?: string,
width?: number,
data?: LineChartSeries[],
) => {
const tickValues =
width && data
? computeLineChartCategoryTickValues({ width, data })
: undefined;
return {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
tickValues,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
};
};
@@ -1,19 +1,19 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields';
import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon';
import { type GroupByChartConfiguration } from '@/page-layout/widgets/graph/types/GroupByChartConfiguration';
import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery';
import { generateGroupByQueryVariablesFromBarChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration';
import { generateGroupByQueryVariablesFromChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromChartConfiguration';
import { useQuery } from '@apollo/client';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated-metadata/graphql';
export const useGraphWidgetGroupByQuery = ({
objectMetadataItemId,
configuration,
}: {
objectMetadataItemId: string;
configuration: BarChartConfiguration;
configuration: GroupByChartConfiguration;
}) => {
const { objectMetadataItem, aggregateField, gqlOperationFilter } =
useGraphWidgetQueryCommon({
@@ -43,9 +43,9 @@ export const useGraphWidgetGroupByQuery = ({
}
const groupByQueryVariables =
generateGroupByQueryVariablesFromBarChartConfiguration({
generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem,
barChartConfiguration: configuration,
chartConfiguration: configuration,
aggregateOperation: aggregateOperation,
});
@@ -6,6 +6,7 @@ import {
import {
type AggregateChartConfiguration,
type BarChartConfiguration,
type LineChartConfiguration,
} from '~/generated/graphql';
export const useGraphWidgetQueryCommon = ({
@@ -13,7 +14,10 @@ export const useGraphWidgetQueryCommon = ({
configuration,
}: {
objectMetadataItemId: string;
configuration: BarChartConfiguration | AggregateChartConfiguration;
configuration:
| BarChartConfiguration
| AggregateChartConfiguration
| LineChartConfiguration;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
@@ -0,0 +1,4 @@
export type ChartValueRange = {
minimum: number;
maximum: number;
};
@@ -0,0 +1,8 @@
import {
type BarChartConfiguration,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
export type GroupByChartConfiguration =
| BarChartConfiguration
| LineChartConfiguration;
@@ -1,59 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Horizontal bar configuration should generate variables with secondary axis 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Horizontal bar configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with composite field 1`] = `
{
"groupBy": [
{
"name": {
"firstName": true,
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with two groupBy fields 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
@@ -0,0 +1,131 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Horizontal bar configuration should generate variables with secondary axis 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Horizontal bar configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with composite field 1`] = `
{
"groupBy": [
{
"name": {
"firstName": true,
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with date field and granularity 1`] = `
{
"groupBy": [
{
"createdAt": {
"granularity": "MONTH",
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with two groupBy fields 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with composite field 1`] = `
{
"groupBy": [
{
"name": {
"firstName": true,
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with date field and granularity 1`] = `
{
"groupBy": [
{
"createdAt": {
"granularity": "MONTH",
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with secondary axis 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with two groupBy fields (multi-series) 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
@@ -0,0 +1,75 @@
import { calculateValueRangeFromValues } from '../calculateValueRangeFromValues';
describe('calculateValueRangeFromValues', () => {
it('should return minimum=0 and maximum=highest value for all positive values', () => {
const values = [5, 10, 15, 20];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 20 });
});
it('should return minimum=lowest value and maximum=0 for all negative values', () => {
const values = [-5, -10, -15, -20];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: -20, maximum: 0 });
});
it('should include zero in range when values cross zero', () => {
const values = [-10, -5, 5, 10];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: -10, maximum: 10 });
});
it('should handle empty array', () => {
const values: number[] = [];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 0 });
});
it('should ignore NaN values', () => {
const values = [5, NaN, 10, NaN, 15];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 15 });
});
it('should handle all NaN values', () => {
const values = [NaN, NaN, NaN];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 0 });
});
it('should handle single value', () => {
const values = [42];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 42 });
});
it('should handle single negative value', () => {
const values = [-42];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: -42, maximum: 0 });
});
it('should handle zero value', () => {
const values = [0];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 0 });
});
});
@@ -0,0 +1,92 @@
import { computeChartCategoryTickValues } from '../computeChartCategoryTickValues';
describe('computeChartCategoryTickValues', () => {
it('should return all values when available size allows', () => {
const values = ['A', 'B', 'C', 'D', 'E'];
const result = computeChartCategoryTickValues({
availableSize: 500,
minimumSizePerTick: 100,
values,
});
expect(result).toEqual(['A', 'B', 'C', 'D', 'E']);
});
it('should return subset of values when space is limited', () => {
const values = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
const result = computeChartCategoryTickValues({
availableSize: 400,
minimumSizePerTick: 100,
values,
});
expect(result.length).toBe(4);
expect(result).toEqual(['A', 'C', 'F', 'H']);
});
it('should return empty array when available size is zero', () => {
const values = ['A', 'B', 'C'];
const result = computeChartCategoryTickValues({
availableSize: 0,
minimumSizePerTick: 100,
values,
});
expect(result).toEqual([]);
});
it('should return empty array when available size is negative', () => {
const values = ['A', 'B', 'C'];
const result = computeChartCategoryTickValues({
availableSize: -100,
minimumSizePerTick: 100,
values,
});
expect(result).toEqual([]);
});
it('should return empty array when values array is empty', () => {
const result = computeChartCategoryTickValues({
availableSize: 500,
minimumSizePerTick: 100,
values: [],
});
expect(result).toEqual([]);
});
it('should handle single value', () => {
const values = ['A'];
const result = computeChartCategoryTickValues({
availableSize: 500,
minimumSizePerTick: 100,
values,
});
expect(result).toEqual(['A']);
});
it('should work with numeric values', () => {
const values = [1, 2, 3, 4, 5, 6, 7, 8];
const result = computeChartCategoryTickValues({
availableSize: 300,
minimumSizePerTick: 100,
values,
});
expect(result.length).toBe(3);
expect(result).toEqual([1, 5, 8]);
});
it('should handle very small available size', () => {
const values = ['A', 'B', 'C', 'D', 'E'];
const result = computeChartCategoryTickValues({
availableSize: 50,
minimumSizePerTick: 100,
values,
});
expect(result).toEqual([]);
});
});
@@ -0,0 +1,55 @@
import { generateChartAggregateFilterKey } from '../generateChartAggregateFilterKey';
describe('generateChartAggregateFilterKey', () => {
it('should generate key with all values provided', () => {
const result = generateChartAggregateFilterKey(0, 100, true);
expect(result).toBe('0-100-true');
});
it('should generate key with negative values', () => {
const result = generateChartAggregateFilterKey(-50, 50, false);
expect(result).toBe('-50-50-false');
});
it('should handle undefined values as empty strings', () => {
const result = generateChartAggregateFilterKey(
undefined,
undefined,
undefined,
);
expect(result).toBe('--');
});
it('should handle null values as empty strings', () => {
const result = generateChartAggregateFilterKey(null, null, null);
expect(result).toBe('--');
});
it('should handle mixed defined and undefined values', () => {
const result = generateChartAggregateFilterKey(10, undefined, true);
expect(result).toBe('10--true');
});
it('should handle zero as valid value', () => {
const result = generateChartAggregateFilterKey(0, 0, false);
expect(result).toBe('0-0-false');
});
it('should handle no arguments', () => {
const result = generateChartAggregateFilterKey();
expect(result).toBe('--');
});
it('should handle decimal values', () => {
const result = generateChartAggregateFilterKey(10.5, 99.9, true);
expect(result).toBe('10.5-99.9-true');
});
});
@@ -1,132 +0,0 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
AggregateOperations,
type BarChartConfiguration,
FieldMetadataType,
GraphType,
} from '~/generated-metadata/graphql';
import { generateGroupByQueryVariablesFromBarChartConfiguration } from '../generateGroupByQueryVariablesFromBarChartConfiguration';
describe('generateGroupByQueryVariablesFromBarChartConfiguration', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'obj-1',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [
{
id: 'field-1',
name: 'stage',
type: FieldMetadataType.TEXT,
},
{
id: 'field-2',
name: 'owner',
type: FieldMetadataType.RELATION,
},
{
id: 'field-3',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
},
{
id: 'field-4',
name: 'name',
type: FieldMetadataType.FULL_NAME,
},
],
} as ObjectMetadataItem;
const buildConfiguration = (
overrides: Partial<BarChartConfiguration>,
): BarChartConfiguration =>
({
__typename: 'BarChartConfiguration',
aggregateFieldMetadataId: 'aggregate-field',
aggregateOperation: AggregateOperations.COUNT,
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
...overrides,
}) as BarChartConfiguration;
describe('Vertical bar configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with two groupBy fields', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
secondaryAxisGroupByFieldMetadataId: 'field-2',
secondaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with composite field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-4',
primaryAxisGroupBySubFieldName: 'firstName',
}),
});
expect(result).toMatchSnapshot();
});
});
describe('Horizontal bar configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with secondary axis', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
secondaryAxisGroupByFieldMetadataId: 'field-2',
}),
});
expect(result).toMatchSnapshot();
});
});
it('should throw error when primary axis field not found', () => {
expect(() =>
generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
primaryAxisGroupByFieldMetadataId: 'invalid-field',
}),
}),
).toThrow('Field with id invalid-field not found in object metadata');
});
});
@@ -0,0 +1,244 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
AggregateOperations,
type BarChartConfiguration,
FieldMetadataType,
GraphType,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
import { generateGroupByQueryVariablesFromChartConfiguration } from '../generateGroupByQueryVariablesFromChartConfiguration';
describe('generateGroupByQueryVariablesFromChartConfiguration', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'obj-1',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [
{
id: 'field-1',
name: 'stage',
type: FieldMetadataType.TEXT,
},
{
id: 'field-2',
name: 'owner',
type: FieldMetadataType.RELATION,
},
{
id: 'field-3',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
},
{
id: 'field-4',
name: 'name',
type: FieldMetadataType.FULL_NAME,
},
],
} as ObjectMetadataItem;
const buildBarChartConfiguration = (
overrides: Partial<BarChartConfiguration>,
): BarChartConfiguration =>
({
__typename: 'BarChartConfiguration',
aggregateFieldMetadataId: 'aggregate-field',
aggregateOperation: AggregateOperations.COUNT,
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
...overrides,
}) as BarChartConfiguration;
const buildLineChartConfiguration = (
overrides: Partial<LineChartConfiguration>,
): LineChartConfiguration =>
({
__typename: 'LineChartConfiguration',
aggregateFieldMetadataId: 'aggregate-field',
aggregateOperation: AggregateOperations.COUNT,
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
...overrides,
}) as LineChartConfiguration;
describe('Bar Chart Configuration', () => {
describe('Vertical bar configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with two groupBy fields', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
secondaryAxisGroupByFieldMetadataId: 'field-2',
secondaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with composite field', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-4',
primaryAxisGroupBySubFieldName: 'firstName',
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with date field and granularity', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-3',
primaryAxisGroupBySubFieldName: null,
primaryAxisDateGranularity: 'MONTH' as any,
}),
});
expect(result).toMatchSnapshot();
});
});
describe('Horizontal bar configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with secondary axis', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
secondaryAxisGroupByFieldMetadataId: 'field-2',
}),
});
expect(result).toMatchSnapshot();
});
});
});
describe('Line Chart Configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with two groupBy fields (multi-series)', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
secondaryAxisGroupByFieldMetadataId: 'field-2',
secondaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with date field and granularity', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-3',
primaryAxisGroupBySubFieldName: null,
primaryAxisDateGranularity: 'MONTH' as any,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with composite field', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-4',
primaryAxisGroupBySubFieldName: 'firstName',
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with secondary axis', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
secondaryAxisGroupByFieldMetadataId: 'field-2',
}),
});
expect(result).toMatchSnapshot();
});
});
describe('Error handling', () => {
it('should throw error when primary axis field not found (bar chart)', () => {
expect(() =>
generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
primaryAxisGroupByFieldMetadataId: 'invalid-field',
}),
}),
).toThrow('Field with id invalid-field not found in object metadata');
});
it('should throw error when primary axis field not found (line chart)', () => {
expect(() =>
generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
primaryAxisGroupByFieldMetadataId: 'invalid-field',
}),
}),
).toThrow('Field with id invalid-field not found in object metadata');
});
});
});
@@ -0,0 +1,234 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import {
AggregateOperations,
FieldMetadataType,
GraphType,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
import { transformOneDimensionalGroupByToLineChartData } from '../transformOneDimensionalGroupByToLineChartData';
describe('transformOneDimensionalGroupByToLineChartData', () => {
const mockAggregateField: FieldMetadataItem = {
id: 'amount-field',
name: 'amount',
label: 'Amount',
type: FieldMetadataType.NUMBER,
} as FieldMetadataItem;
const mockGroupByFieldX: FieldMetadataItem = {
id: 'stage-field',
name: 'stage',
label: 'Stage',
type: FieldMetadataType.TEXT,
} as FieldMetadataItem;
const mockDateGroupByField: FieldMetadataItem = {
id: 'created-at-field',
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DATE_TIME,
} as FieldMetadataItem;
const mockObjectMetadataItem = {
id: 'opportunity-object',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [mockAggregateField, mockGroupByFieldX, mockDateGroupByField],
} as ObjectMetadataItem;
const buildConfiguration = (
overrides: Partial<LineChartConfiguration> = {},
): LineChartConfiguration =>
({
__typename: 'LineChartConfiguration',
graphType: GraphType.LINE,
aggregateFieldMetadataId: 'amount-field',
aggregateOperation: AggregateOperations.SUM,
primaryAxisGroupByFieldMetadataId: 'stage-field',
color: 'blue',
...overrides,
}) as LineChartConfiguration;
describe('Categorical X-axis', () => {
it('should transform simple categorical groupBy results', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['Qualification'],
sumAmount: 150000,
},
{
groupByDimensionValues: ['Proposal'],
sumAmount: 280000,
},
{
groupByDimensionValues: ['Closed Won'],
sumAmount: 450000,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toHaveLength(1);
expect(result.series[0]).toMatchObject({
id: 'amount',
label: 'Amount',
color: 'blue',
});
expect(result.series[0].data).toEqual([
{ x: 'Qualification', y: 150000 },
{ x: 'Proposal', y: 280000 },
{ x: 'Closed Won', y: 450000 },
]);
expect(result.hasTooManyGroups).toBe(false);
});
it('should filter out null aggregate values', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['Stage B'],
sumAmount: null,
},
{
groupByDimensionValues: ['Stage C'],
sumAmount: 200,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series[0].data).toEqual([
{ x: 'Stage A', y: 100 },
{ x: 'Stage C', y: 200 },
]);
expect(result.hasTooManyGroups).toBe(false);
});
});
describe('Time-series X-axis', () => {
it('should transform date-based groupBy results', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01'],
sumAmount: 50000,
},
{
groupByDimensionValues: ['2024-02'],
sumAmount: 75000,
},
{
groupByDimensionValues: ['2024-03'],
sumAmount: 60000,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockDateGroupByField,
aggregateField: mockAggregateField,
configuration: buildConfiguration({
primaryAxisGroupByFieldMetadataId: 'created-at-field',
primaryAxisDateGranularity: 'MONTH' as any,
}),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toHaveLength(1);
expect(result.series[0].data).toHaveLength(3);
expect(result.series[0].data[0]).toHaveProperty('x');
expect(result.series[0].data[0]).toHaveProperty('y', 50000);
});
});
describe('Edge cases', () => {
it('should handle empty results', () => {
const result = transformOneDimensionalGroupByToLineChartData({
rawResults: [],
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toHaveLength(1);
expect(result.series[0].data).toEqual([]);
expect(result.hasTooManyGroups).toBe(false);
});
it('should use default color when not specified', () => {
const result = transformOneDimensionalGroupByToLineChartData({
rawResults: [
{
groupByDimensionValues: ['Test'],
sumAmount: 100,
},
],
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration({ color: undefined }),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series[0].color).toBeDefined();
expect(result.hasTooManyGroups).toBe(false);
});
it('should handle COUNT operation', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['Stage A'],
_count: 5,
},
{
groupByDimensionValues: ['Stage B'],
_count: 10,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration({
aggregateOperation: AggregateOperations.COUNT,
}),
aggregateOperation: '_count',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series[0].data).toEqual([
{ x: 'Stage A', y: 5 },
{ x: 'Stage B', y: 10 },
]);
expect(result.hasTooManyGroups).toBe(false);
});
});
});
@@ -0,0 +1,310 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import {
AggregateOperations,
FieldMetadataType,
GraphType,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
import { transformTwoDimensionalGroupByToLineChartData } from '../transformTwoDimensionalGroupByToLineChartData';
describe('transformTwoDimensionalGroupByToLineChartData', () => {
const mockAggregateField: FieldMetadataItem = {
id: 'amount-field',
name: 'amount',
label: 'Amount',
type: FieldMetadataType.NUMBER,
} as FieldMetadataItem;
const mockGroupByFieldX: FieldMetadataItem = {
id: 'created-at-field',
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DATE_TIME,
} as FieldMetadataItem;
const mockGroupByFieldY: FieldMetadataItem = {
id: 'stage-field',
name: 'stage',
label: 'Stage',
type: FieldMetadataType.TEXT,
} as FieldMetadataItem;
const mockObjectMetadataItem = {
id: 'opportunity-object',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [mockAggregateField, mockGroupByFieldX, mockGroupByFieldY],
} as ObjectMetadataItem;
const buildConfiguration = (
overrides: Partial<LineChartConfiguration> = {},
): LineChartConfiguration =>
({
__typename: 'LineChartConfiguration',
graphType: GraphType.LINE,
aggregateFieldMetadataId: 'amount-field',
aggregateOperation: AggregateOperations.SUM,
primaryAxisGroupByFieldMetadataId: 'created-at-field',
secondaryAxisGroupByFieldMetadataId: 'stage-field',
color: 'blue',
...overrides,
}) as LineChartConfiguration;
describe('Multi-series transformation', () => {
it('should create multiple series from 2D groupBy results', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01', 'Qualification'],
sumAmount: 50000,
},
{
groupByDimensionValues: ['2024-01', 'Proposal'],
sumAmount: 75000,
},
{
groupByDimensionValues: ['2024-02', 'Qualification'],
sumAmount: 60000,
},
{
groupByDimensionValues: ['2024-02', 'Proposal'],
sumAmount: 90000,
},
{
groupByDimensionValues: ['2024-03', 'Qualification'],
sumAmount: 55000,
},
{
groupByDimensionValues: ['2024-03', 'Proposal'],
sumAmount: 80000,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toHaveLength(2);
expect(result.series[0]).toMatchObject({
id: expect.any(String),
label: expect.any(String),
color: 'blue',
});
expect(result.series[0].data).toHaveLength(3);
expect(result.series[1].data).toHaveLength(3);
expect(result.series[0].data[0]).toHaveProperty('x');
expect(result.series[0].data[0]).toHaveProperty('y');
result.series.forEach((series) => {
series.data.forEach((point) => {
expect(point).toHaveProperty('x');
expect(point).toHaveProperty('y');
});
});
expect(result.hasTooManyGroups).toBe(false);
});
it('should preserve backend ordering of data points', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01', 'Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02', 'Stage A'],
sumAmount: 200,
},
{
groupByDimensionValues: ['2024-03', 'Stage A'],
sumAmount: 300,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
const series = result.series[0];
expect(series.data[0].y).toBe(100);
expect(series.data[1].y).toBe(200);
expect(series.data[2].y).toBe(300);
expect(result.hasTooManyGroups).toBe(false);
});
it('should normalize sparse data (all series share same X values, with 0 for missing)', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01', 'Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02', 'Stage A'],
sumAmount: 200,
},
{
groupByDimensionValues: ['2024-01', 'Stage B'],
sumAmount: 150,
},
{
groupByDimensionValues: ['2024-03', 'Stage B'],
sumAmount: 250,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
const stageA = result.series.find((s) => s.id === 'Stage A');
expect(stageA?.data).toHaveLength(3);
expect(stageA?.data[0].y).toBe(100);
expect(stageA?.data[1].y).toBe(200);
expect(stageA?.data[2].y).toBe(0);
const stageB = result.series.find((s) => s.id === 'Stage B');
expect(stageB?.data).toHaveLength(3);
expect(stageB?.data[0].y).toBe(150);
expect(stageB?.data[1].y).toBe(0);
expect(stageB?.data[2].y).toBe(250);
expect(result.hasTooManyGroups).toBe(false);
});
it('should filter out null aggregate values', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01', 'Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02', 'Stage A'],
sumAmount: null,
},
{
groupByDimensionValues: ['2024-03', 'Stage A'],
sumAmount: 200,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series[0].data).toHaveLength(2);
expect(result.series[0].data.map((d) => d.y)).toEqual([100, 200]);
expect(result.hasTooManyGroups).toBe(false);
});
});
describe('Edge cases', () => {
it('should handle empty results', () => {
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults: [],
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toEqual([]);
expect(result.hasTooManyGroups).toBe(false);
});
it('should skip results with missing dimension values', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02', 'Stage A'],
sumAmount: 200,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toHaveLength(1);
expect(result.series[0].data).toHaveLength(1);
expect(result.hasTooManyGroups).toBe(false);
});
it('should handle COUNT operation', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01', 'Stage A'],
_count: 5,
},
{
groupByDimensionValues: ['2024-02', 'Stage A'],
_count: 10,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration({
aggregateOperation: AggregateOperations.COUNT,
}),
aggregateOperation: '_count',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series[0].data.map((d) => d.y)).toEqual([5, 10]);
expect(result.hasTooManyGroups).toBe(false);
});
});
});
@@ -0,0 +1,22 @@
import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange';
export const calculateValueRangeFromValues = (
values: number[],
): ChartValueRange => {
let minimumValue = 0;
let maximumValue = 0;
for (const value of values) {
if (isNaN(value)) {
continue;
}
minimumValue = Math.min(minimumValue, value);
maximumValue = Math.max(maximumValue, value);
}
minimumValue = Math.min(minimumValue, 0);
maximumValue = Math.max(maximumValue, 0);
return { minimum: minimumValue, maximum: maximumValue };
};
@@ -0,0 +1,16 @@
export const computeCategoryTickValues = (
numberOfTicks: number,
totalValues: number,
): number[] => {
if (numberOfTicks <= 0) return [];
if (numberOfTicks === 1) return [0];
if (numberOfTicks >= totalValues)
return Array.from({ length: totalValues }, (_, i) => i);
const step = (totalValues - 1) / (numberOfTicks - 1);
return Array.from({ length: numberOfTicks }, (_, i) => {
const index = Math.min(Math.round(i * step), totalValues - 1);
return index;
});
};
@@ -0,0 +1,20 @@
import { computeCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeCategoryTickValues';
export const computeChartCategoryTickValues = ({
availableSize,
minimumSizePerTick,
values,
}: {
availableSize: number;
minimumSizePerTick: number;
values: (string | number)[];
}): (string | number)[] => {
if (availableSize <= 0 || values.length === 0) {
return [];
}
const numberOfTicks = Math.floor(availableSize / minimumSizePerTick);
const tickIndices = computeCategoryTickValues(numberOfTicks, values.length);
return tickIndices.map((index) => values[index]);
};
@@ -0,0 +1,7 @@
export const generateChartAggregateFilterKey = (
rangeMin?: number | null,
rangeMax?: number | null,
omitNullValues?: boolean | null,
): string => {
return `${rangeMin ?? ''}-${rangeMax ?? ''}-${omitNullValues ?? ''}`;
};
@@ -1,4 +1,5 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type GroupByChartConfiguration } from '@/page-layout/widgets/graph/types/GroupByChartConfiguration';
import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy';
import {
type AggregateOrderByWithGroupByField,
@@ -7,29 +8,27 @@ import {
type ObjectRecordOrderByWithGroupByDateField,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated/graphql';
import { buildGroupByFieldObject } from './buildGroupByFieldObject';
export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
export const generateGroupByQueryVariablesFromChartConfiguration = ({
objectMetadataItem,
barChartConfiguration,
chartConfiguration,
aggregateOperation,
}: {
objectMetadataItem: ObjectMetadataItem;
barChartConfiguration: BarChartConfiguration;
chartConfiguration: GroupByChartConfiguration;
aggregateOperation?: string;
}) => {
const groupByFieldXId =
barChartConfiguration.primaryAxisGroupByFieldMetadataId;
const groupByFieldXId = chartConfiguration.primaryAxisGroupByFieldMetadataId;
const groupByFieldYId =
barChartConfiguration.secondaryAxisGroupByFieldMetadataId;
chartConfiguration.secondaryAxisGroupByFieldMetadataId;
const groupBySubFieldNameX =
barChartConfiguration.primaryAxisGroupBySubFieldName ?? undefined;
chartConfiguration.primaryAxisGroupBySubFieldName ?? undefined;
const groupBySubFieldNameY =
barChartConfiguration.secondaryAxisGroupBySubFieldName ?? undefined;
chartConfiguration.secondaryAxisGroupBySubFieldName ?? undefined;
const groupByFieldX = objectMetadataItem.fields.find(
(field) => field.id === groupByFieldXId,
@@ -54,7 +53,7 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
field: groupByFieldX,
subFieldName: groupBySubFieldNameX,
dateGranularity:
barChartConfiguration.primaryAxisDateGranularity ?? undefined,
chartConfiguration.primaryAxisDateGranularity ?? undefined,
}),
);
@@ -64,8 +63,7 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
field: groupByFieldY,
subFieldName: groupBySubFieldNameY,
dateGranularity:
barChartConfiguration.secondaryAxisGroupByDateGranularity ??
undefined,
chartConfiguration.secondaryAxisGroupByDateGranularity ?? undefined,
}),
);
}
@@ -77,33 +75,31 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
| ObjectRecordOrderByForCompositeField
> = [];
if (isDefined(barChartConfiguration.primaryAxisOrderBy)) {
if (isDefined(chartConfiguration.primaryAxisOrderBy)) {
orderBy.push(
getGroupByOrderBy({
graphOrderBy: barChartConfiguration.primaryAxisOrderBy,
graphOrderBy: chartConfiguration.primaryAxisOrderBy,
groupByField: groupByFieldX,
groupBySubFieldName:
barChartConfiguration.primaryAxisGroupBySubFieldName,
groupBySubFieldName: chartConfiguration.primaryAxisGroupBySubFieldName,
aggregateOperation,
dateGranularity:
barChartConfiguration.primaryAxisDateGranularity ?? undefined,
chartConfiguration.primaryAxisDateGranularity ?? undefined,
}),
);
}
if (
isDefined(groupByFieldY) &&
isDefined(barChartConfiguration.secondaryAxisOrderBy)
isDefined(chartConfiguration.secondaryAxisOrderBy)
) {
orderBy.push(
getGroupByOrderBy({
graphOrderBy: barChartConfiguration.secondaryAxisOrderBy,
graphOrderBy: chartConfiguration.secondaryAxisOrderBy,
groupByField: groupByFieldY,
groupBySubFieldName:
barChartConfiguration.secondaryAxisGroupBySubFieldName,
chartConfiguration.secondaryAxisGroupBySubFieldName,
aggregateOperation,
dateGranularity:
barChartConfiguration.secondaryAxisGroupByDateGranularity ??
undefined,
chartConfiguration.secondaryAxisGroupByDateGranularity ?? undefined,
}),
);
}
@@ -0,0 +1,30 @@
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { GraphOrderBy } from '~/generated/graphql';
const normalizeXValue = (x: number | string | Date): string => {
if (x instanceof Date) {
return x.toISOString();
}
return String(x);
};
export const sortLineChartDataPoints = ({
dataPoints,
orderBy,
}: {
dataPoints: LineChartDataPoint[];
orderBy?: GraphOrderBy | null;
}): LineChartDataPoint[] => {
switch (orderBy) {
case GraphOrderBy.FIELD_ASC:
return [...dataPoints].sort((a, b) =>
normalizeXValue(a.x).localeCompare(normalizeXValue(b.x)),
);
case GraphOrderBy.FIELD_DESC:
return [...dataPoints].sort((a, b) =>
normalizeXValue(b.x).localeCompare(normalizeXValue(a.x)),
);
default:
return dataPoints;
}
};
@@ -0,0 +1,19 @@
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { GraphOrderBy } from '~/generated/graphql';
export const sortLineChartSeries = ({
series,
orderByY,
}: {
series: LineChartSeries[];
orderByY?: GraphOrderBy | null;
}): LineChartSeries[] => {
switch (orderByY) {
case GraphOrderBy.FIELD_ASC:
return [...series].sort((a, b) => b.id.localeCompare(a.id));
case GraphOrderBy.FIELD_DESC:
return [...series].sort((a, b) => a.id.localeCompare(b.id));
default:
return series;
}
};
@@ -0,0 +1,140 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults';
import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData';
import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData';
import { isDefined } from 'twenty-shared/utils';
import {
AxisNameDisplay,
type LineChartConfiguration,
} from '~/generated/graphql';
type TransformGroupByDataToLineChartDataParams = {
groupByData: Record<string, GroupByRawResult[]> | null | undefined;
objectMetadataItem: ObjectMetadataItem;
configuration: LineChartConfiguration;
aggregateOperation: string;
};
type TransformGroupByDataToLineChartDataResult = {
series: LineChartSeries[];
xAxisLabel?: string;
yAxisLabel?: string;
showDataLabels: boolean;
hasTooManyGroups: boolean;
};
const EMPTY_LINE_CHART_RESULT: TransformGroupByDataToLineChartDataResult = {
series: [],
xAxisLabel: undefined,
yAxisLabel: undefined,
showDataLabels: false,
hasTooManyGroups: false,
};
export const transformGroupByDataToLineChartData = ({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation,
}: TransformGroupByDataToLineChartDataParams): TransformGroupByDataToLineChartDataResult => {
if (!isDefined(groupByData)) {
return EMPTY_LINE_CHART_RESULT;
}
const groupByFieldX = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.primaryAxisGroupByFieldMetadataId,
);
const groupByFieldY = isDefined(
configuration.secondaryAxisGroupByFieldMetadataId,
)
? objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.secondaryAxisGroupByFieldMetadataId,
)
: undefined;
const aggregateField = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.aggregateFieldMetadataId,
);
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
return EMPTY_LINE_CHART_RESULT;
}
const primaryAxisSubFieldName =
configuration.primaryAxisGroupBySubFieldName ?? undefined;
const queryName = getGroupByQueryName(objectMetadataItem);
const rawResults = groupByData[queryName];
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
return EMPTY_LINE_CHART_RESULT;
}
const filteredResults = filterGroupByResults({
rawResults,
filterOptions: {
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
omitNullValues: configuration.omitNullValues ?? false,
},
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
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 baseResult = isDefined(groupByFieldY)
? transformTwoDimensionalGroupByToLineChartData({
rawResults: filteredResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
})
: transformOneDimensionalGroupByToLineChartData({
rawResults: filteredResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
});
return {
...baseResult,
xAxisLabel,
yAxisLabel,
showDataLabels,
};
};
@@ -0,0 +1,100 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant';
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { sortLineChartDataPoints } from '@/page-layout/widgets/graph/utils/sortLineChartDataPoints';
import { isDefined } from 'twenty-shared/utils';
import { type LineChartConfiguration } from '~/generated/graphql';
type TransformOneDimensionalGroupByToLineChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: LineChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
};
type TransformOneDimensionalGroupByToLineChartDataResult = {
series: LineChartSeries[];
hasTooManyGroups: boolean;
};
export const transformOneDimensionalGroupByToLineChartData = ({
rawResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
}: TransformOneDimensionalGroupByToLineChartDataParams): TransformOneDimensionalGroupByToLineChartDataResult => {
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
const limitedResults = rawResults.slice(
0,
LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS,
);
const unsortedData: LineChartDataPoint[] = limitedResults
.map((result) => {
const dimensionValues = result.groupByDimensionValues;
const rawAggregateValue = result[aggregateOperation];
if (!isDefined(rawAggregateValue)) {
return null;
}
const xValue = isDefined(dimensionValues?.[0])
? formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByFieldX,
dateGranularity:
configuration.primaryAxisDateGranularity ?? undefined,
subFieldName: primaryAxisSubFieldName ?? undefined,
})
: '';
const aggregateValue = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
return {
x: xValue,
y: aggregateValue,
};
})
.filter((point) => isDefined(point));
const data = sortLineChartDataPoints({
dataPoints: unsortedData,
orderBy: configuration.primaryAxisOrderBy,
});
const series: LineChartSeries[] = [
{
id: aggregateField.name,
label: aggregateField.label,
color: (configuration.color ?? GRAPH_DEFAULT_COLOR) as GraphColor,
data,
},
];
return {
series,
hasTooManyGroups:
rawResults.length > LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS,
};
};
@@ -0,0 +1,130 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant';
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { sortLineChartDataPoints } from '@/page-layout/widgets/graph/utils/sortLineChartDataPoints';
import { sortLineChartSeries } from '@/page-layout/widgets/graph/utils/sortLineChartSeries';
import { isDefined } from 'twenty-shared/utils';
import { type LineChartConfiguration } from '~/generated/graphql';
type TransformTwoDimensionalGroupByToLineChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
groupByFieldY: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: LineChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
};
type TransformTwoDimensionalGroupByToLineChartDataResult = {
series: LineChartSeries[];
hasTooManyGroups: boolean;
};
export const transformTwoDimensionalGroupByToLineChartData = ({
rawResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
}: TransformTwoDimensionalGroupByToLineChartDataParams): TransformTwoDimensionalGroupByToLineChartDataResult => {
const seriesMap = new Map<string, Map<string, number>>();
const allXValues: string[] = [];
const xValueSet = new Set<string>();
let hasTooManyGroups = false;
rawResults.forEach((result) => {
const dimensionValues = result.groupByDimensionValues;
if (!isDefined(dimensionValues) || dimensionValues.length < 2) return;
const rawAggregateValue = result[aggregateOperation];
if (!isDefined(rawAggregateValue)) return;
const xValue = formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByFieldX,
dateGranularity: configuration.primaryAxisDateGranularity ?? undefined,
subFieldName: primaryAxisSubFieldName ?? undefined,
});
// TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600)
const isNewX = !xValueSet.has(xValue);
if (isNewX && xValueSet.size >= LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS) {
hasTooManyGroups = true;
return;
}
if (isNewX) {
xValueSet.add(xValue);
allXValues.push(xValue);
}
const seriesKey = formatDimensionValue({
value: dimensionValues[1],
fieldMetadata: groupByFieldY,
dateGranularity:
configuration.secondaryAxisGroupByDateGranularity ?? undefined,
subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined,
});
const aggregateValue = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
if (!isDefined(aggregateValue)) return;
if (!seriesMap.has(seriesKey)) {
seriesMap.set(seriesKey, new Map());
}
seriesMap.get(seriesKey)!.set(xValue, aggregateValue);
});
const unsortedSeries: LineChartSeries[] = Array.from(seriesMap.entries()).map(
([seriesKey, xToYMap]) => {
const unsortedData: LineChartDataPoint[] = allXValues.map((xValue) => ({
x: xValue,
y: xToYMap.get(xValue) ?? 0,
}));
const data = sortLineChartDataPoints({
dataPoints: unsortedData,
orderBy: configuration.primaryAxisOrderBy,
});
return {
id: seriesKey,
label: seriesKey,
color: configuration.color as GraphColor,
data,
};
},
);
const series = sortLineChartSeries({
series: unsortedSeries,
orderByY: configuration.secondaryAxisOrderBy,
});
return {
series,
hasTooManyGroups,
};
};
@@ -0,0 +1,11 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState';
export const widgetCardHoveredComponentFamilyState = createComponentFamilyState<
boolean,
string
>({
key: 'widgetCardHoveredComponentFamilyState',
defaultValue: false,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,3 +1,5 @@
import { widgetCardHoveredComponentFamilyState } from '@/page-layout/widgets/states/widgetCardHoveredComponentFamilyState';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
@@ -10,7 +12,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { isDefined } from 'twenty-shared/utils';
export type WidgetCardHeaderProps = {
isWidgetCardHovered: boolean;
widgetId: string;
isInEditMode: boolean;
isEmpty?: boolean;
title: string;
@@ -50,7 +52,7 @@ const StyledIconButtonContainer = styled(motion.div)`
`;
export const WidgetCardHeader = ({
isWidgetCardHovered = false,
widgetId,
isEmpty = false,
isInEditMode = false,
isResizing = false,
@@ -61,6 +63,11 @@ export const WidgetCardHeader = ({
}: WidgetCardHeaderProps) => {
const theme = useTheme();
const isWidgetCardHovered = useRecoilComponentFamilyValue(
widgetCardHoveredComponentFamilyState,
widgetId,
);
return (
<StyledWidgetCardHeader className={className}>
<AnimatePresence>
@@ -86,7 +86,7 @@ export const Default: Story = {
isDragging={args.isDragging}
>
<WidgetCardHeader
isWidgetCardHovered={false}
widgetId="story-widget"
isInEditMode={true}
onRemove={() => {}}
title="Widget name"
@@ -195,13 +195,13 @@ export const Catalog: CatalogStory<Story, typeof WidgetCard> = {
isInPinnedTab={isInPinnedTab}
>
<WidgetCardHeader
widgetId="catalog-widget"
forbiddenDisplay={
hasRestriction ? <ForbiddenFieldDisplay /> : undefined
}
isInEditMode={!isReadMode}
onRemove={!isReadMode ? () => {} : undefined}
title="Widget name"
isWidgetCardHovered={args.state === 'Hover'}
/>
<WidgetCardContent
pageLayoutType={pageLayoutType}
@@ -134,6 +134,13 @@ export class LineChartConfigurationDTO {
@IsOptional()
filter?: ObjectRecordFilter;
@Field(() => Boolean, {
nullable: true,
})
@IsBoolean()
@IsOptional()
isStacked?: boolean;
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
@IsTimeZone()
@IsOptional()