connect line chart to backend (#15657)
https://github.com/user-attachments/assets/b797c5a8-32a8-4d3e-9fb1-7541d107a19f optimizations: before: https://github.com/user-attachments/assets/bf45a3f1-5f89-40c1-9ce4-c2d912b77d63 after: https://github.com/user-attachments/assets/61bfd603-7b63-4695-8ae8-68fe7d3bfea0
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+8
-2
@@ -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) => {
|
||||
|
||||
+20
-10
@@ -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) => {
|
||||
|
||||
+10
-2
@@ -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}
|
||||
|
||||
+7
-1
@@ -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,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
+1
@@ -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`,
|
||||
|
||||
+12
@@ -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],
|
||||
};
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+11
@@ -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({
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+1
@@ -60,6 +60,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
|
||||
color
|
||||
description
|
||||
filter
|
||||
isStacked
|
||||
timezone
|
||||
firstDayOfTheWeek
|
||||
}
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+26
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
-30
@@ -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;
|
||||
|
||||
+3
-3
@@ -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,
|
||||
);
|
||||
+9
-1
@@ -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',
|
||||
},
|
||||
|
||||
+22
-191
@@ -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: {
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const CHART_LEGEND_ITEM_THRESHOLD = 10;
|
||||
+23
-27
@@ -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) ||
|
||||
|
||||
+28
-33
@@ -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) => {
|
||||
|
||||
+9
-12
@@ -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
@@ -1 +0,0 @@
|
||||
export const BAR_CHART_LEGEND_ITEM_THRESHOLD = 10;
|
||||
+13
@@ -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,
|
||||
});
|
||||
+26
-21
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+13
-13
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
-37
@@ -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 };
|
||||
};
|
||||
+16
-30
@@ -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);
|
||||
};
|
||||
|
||||
+19
@@ -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);
|
||||
};
|
||||
+14
-17
@@ -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
-2
@@ -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>[]>();
|
||||
|
||||
+30
-29
@@ -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}
|
||||
/>
|
||||
|
||||
+91
-89
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+80
@@ -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
@@ -1 +1 @@
|
||||
export const LINE_CHART_CROSSHAIR_TRANSITION_DAMPING = 20;
|
||||
export const LINE_CHART_CROSSHAIR_TRANSITION_DAMPING = 30;
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_IS_STACKED_DEFAULT = true;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS = 50;
|
||||
+57
@@ -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,
|
||||
};
|
||||
};
|
||||
+10
@@ -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,
|
||||
});
|
||||
+15
@@ -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,
|
||||
});
|
||||
+18
@@ -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);
|
||||
};
|
||||
+34
@@ -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,
|
||||
});
|
||||
};
|
||||
+23
-8
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+5
-5
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+5
-1
@@ -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;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import {
|
||||
type BarChartConfiguration,
|
||||
type LineChartConfiguration,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export type GroupByChartConfiguration =
|
||||
| BarChartConfiguration
|
||||
| LineChartConfiguration;
|
||||
-59
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
+131
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
+75
@@ -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 });
|
||||
});
|
||||
});
|
||||
+92
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+55
@@ -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');
|
||||
});
|
||||
});
|
||||
-132
@@ -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');
|
||||
});
|
||||
});
|
||||
+244
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+234
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+310
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+22
@@ -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 };
|
||||
};
|
||||
+16
@@ -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;
|
||||
});
|
||||
};
|
||||
+20
@@ -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]);
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export const generateChartAggregateFilterKey = (
|
||||
rangeMin?: number | null,
|
||||
rangeMax?: number | null,
|
||||
omitNullValues?: boolean | null,
|
||||
): string => {
|
||||
return `${rangeMin ?? ''}-${rangeMax ?? ''}-${omitNullValues ?? ''}`;
|
||||
};
|
||||
+18
-22
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
};
|
||||
+19
@@ -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;
|
||||
}
|
||||
};
|
||||
+140
@@ -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,
|
||||
};
|
||||
};
|
||||
+100
@@ -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,
|
||||
};
|
||||
};
|
||||
+130
@@ -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,
|
||||
};
|
||||
};
|
||||
+11
@@ -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,
|
||||
});
|
||||
+9
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+7
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user