Split bar graph into two distinct horizontal and vertical bars (#15061)
### Summary Split BAR into VERTICAL_BAR and HORIZONTAL_BAR as separate chart types. ### The Problem Initially wanted a simple vertical/horizontal toggle for bar charts, but ran into a GraphQL union type constraint: union types can't have the same field name with different nullability. - Vertical bars need groupByFieldMetadataIdX as required (categories on X) - Horizontal bars need groupByFieldMetadataIdY as required (categories on Y) - GraphQL schema generation fails with this setup ### The Solution Use semantic primaryAxis and secondaryAxis naming that's orientation-agnostic: - primaryAxisGroupByFieldMetadataId = main grouping field (e.g., "Company Name") - secondaryAxisGroupByFieldMetadataId = optional secondary grouping (e.g., "Stage") These fields have consistent meaning regardless of orientation. The visual mapping happens at the UI layer: - Vertical bars: primary data renders on X-axis, secondary on Y-axis - Horizontal bars: primary data renders on Y-axis, secondary on X-axis Both chart types share the same DTO structure with consistent nullability. ### What Changed - Split GraphType.BAR → VERTICAL_BAR | HORIZONTAL_BAR - Renamed fields: primaryAxisGroupByFieldMetadataId, secondaryAxisGroupByFieldMetadataId (+ subfield variants) - useChartSettingsValues(): Maps semantic fields to setting values (no swapping) - getBarChartSettings(): Dynamically arranges settings panel based on orientation - transformGroupByDataToBarChartData(): Maps semantic fields to Nivo's layout prop video QA https://github.com/user-attachments/assets/479061b5-712e-4ca6-9858-95273d1f16c1
This commit is contained in:
+9
-8
@@ -3,6 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
@@ -24,9 +25,9 @@ import { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.
|
||||
@ObjectType('BarChartConfiguration')
|
||||
export class BarChartConfigurationDTO {
|
||||
@Field(() => GraphType)
|
||||
@IsEnum(GraphType)
|
||||
@IsIn([GraphType.VERTICAL_BAR, GraphType.HORIZONTAL_BAR])
|
||||
@IsNotEmpty()
|
||||
graphType: GraphType.BAR;
|
||||
graphType: GraphType.VERTICAL_BAR | GraphType.HORIZONTAL_BAR;
|
||||
|
||||
@Field(() => UUIDScalarType)
|
||||
@IsUUID()
|
||||
@@ -41,32 +42,32 @@ export class BarChartConfigurationDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
groupByFieldMetadataIdX: string;
|
||||
primaryAxisGroupByFieldMetadataId: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupBySubFieldNameX?: string;
|
||||
primaryAxisGroupBySubFieldName?: string;
|
||||
|
||||
@Field(() => GraphOrderBy, { nullable: true })
|
||||
@IsEnum(GraphOrderBy)
|
||||
@IsOptional()
|
||||
orderByX?: GraphOrderBy;
|
||||
primaryAxisOrderBy?: GraphOrderBy;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
groupByFieldMetadataIdY?: string;
|
||||
secondaryAxisGroupByFieldMetadataId?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupBySubFieldNameY?: string;
|
||||
secondaryAxisGroupBySubFieldName?: string;
|
||||
|
||||
@Field(() => GraphOrderBy, { nullable: true })
|
||||
@IsEnum(GraphOrderBy)
|
||||
@IsOptional()
|
||||
orderByY?: GraphOrderBy;
|
||||
secondaryAxisOrderBy?: GraphOrderBy;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@IsBoolean()
|
||||
|
||||
+6
-6
@@ -40,12 +40,12 @@ export class LineChartConfigurationDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
groupByFieldMetadataIdX: string;
|
||||
primaryAxisGroupByFieldMetadataId: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupBySubFieldNameX?: string;
|
||||
primaryAxisGroupBySubFieldName?: string;
|
||||
|
||||
@Field(() => GraphOrderBy, {
|
||||
nullable: true,
|
||||
@@ -53,22 +53,22 @@ export class LineChartConfigurationDTO {
|
||||
})
|
||||
@IsEnum(GraphOrderBy)
|
||||
@IsOptional()
|
||||
orderByX?: GraphOrderBy;
|
||||
primaryAxisOrderBy?: GraphOrderBy;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
groupByFieldMetadataIdY?: string;
|
||||
secondaryAxisGroupByFieldMetadataId?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
groupBySubFieldNameY?: string;
|
||||
secondaryAxisGroupBySubFieldName?: string;
|
||||
|
||||
@Field(() => GraphOrderBy, { nullable: true })
|
||||
@IsEnum(GraphOrderBy)
|
||||
@IsOptional()
|
||||
orderByY?: GraphOrderBy;
|
||||
secondaryAxisOrderBy?: GraphOrderBy;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@IsBoolean()
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ export const WidgetConfiguration = createUnionType({
|
||||
configuration.configurationType === WidgetConfigurationType.CHART_CONFIG
|
||||
) {
|
||||
switch (configuration.graphType) {
|
||||
case GraphType.BAR:
|
||||
case GraphType.VERTICAL_BAR:
|
||||
case GraphType.HORIZONTAL_BAR:
|
||||
return BarChartConfigurationDTO;
|
||||
case GraphType.LINE:
|
||||
return LineChartConfigurationDTO;
|
||||
|
||||
@@ -4,7 +4,8 @@ export enum GraphType {
|
||||
NUMBER = 'NUMBER',
|
||||
GAUGE = 'GAUGE',
|
||||
PIE = 'PIE',
|
||||
BAR = 'BAR',
|
||||
VERTICAL_BAR = 'VERTICAL_BAR',
|
||||
HORIZONTAL_BAR = 'HORIZONTAL_BAR',
|
||||
LINE = 'LINE',
|
||||
}
|
||||
|
||||
|
||||
+45
-13
@@ -1,12 +1,15 @@
|
||||
import {
|
||||
INVALID_BAR_CHART_CONFIG_MISSING_GROUP_BY,
|
||||
INVALID_VERTICAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
|
||||
INVALID_HORIZONTAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
|
||||
INVALID_IFRAME_CONFIG_BAD_URL,
|
||||
INVALID_IFRAME_CONFIG_EMPTY_URL,
|
||||
INVALID_IFRAME_CONFIG_MISSING_URL,
|
||||
INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
|
||||
INVALID_NUMBER_CHART_CONFIG_MISSING_FIELDS,
|
||||
TEST_BAR_CHART_CONFIG,
|
||||
TEST_BAR_CHART_CONFIG_MINIMAL,
|
||||
TEST_VERTICAL_BAR_CHART_CONFIG,
|
||||
TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL,
|
||||
TEST_HORIZONTAL_BAR_CHART_CONFIG,
|
||||
TEST_HORIZONTAL_BAR_CHART_CONFIG_MINIMAL,
|
||||
TEST_GAUGE_CHART_CONFIG,
|
||||
TEST_GAUGE_CHART_CONFIG_MINIMAL,
|
||||
TEST_IFRAME_CONFIG,
|
||||
@@ -99,32 +102,61 @@ describe('validateAndTransformWidgetConfiguration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('BAR graph', () => {
|
||||
it('should validate full bar graph configuration', () => {
|
||||
describe('VERTICAL_BAR graph', () => {
|
||||
it('should validate full vertical bar graph configuration', () => {
|
||||
const result = validateAndTransformWidgetConfiguration(
|
||||
WidgetType.GRAPH,
|
||||
TEST_BAR_CHART_CONFIG,
|
||||
TEST_VERTICAL_BAR_CHART_CONFIG,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject(TEST_BAR_CHART_CONFIG);
|
||||
expect(result).toMatchObject(TEST_VERTICAL_BAR_CHART_CONFIG);
|
||||
});
|
||||
|
||||
it('should validate minimal bar graph configuration', () => {
|
||||
it('should validate minimal vertical bar graph configuration', () => {
|
||||
const result = validateAndTransformWidgetConfiguration(
|
||||
WidgetType.GRAPH,
|
||||
TEST_BAR_CHART_CONFIG_MINIMAL,
|
||||
TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject(TEST_BAR_CHART_CONFIG_MINIMAL);
|
||||
expect(result).toMatchObject(TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL);
|
||||
});
|
||||
|
||||
it('should throw error for partial bar graph configuration with missing required fields', () => {
|
||||
it('should throw error for partial vertical bar graph configuration with missing required fields', () => {
|
||||
expect(() =>
|
||||
validateAndTransformWidgetConfiguration(
|
||||
WidgetType.GRAPH,
|
||||
INVALID_BAR_CHART_CONFIG_MISSING_GROUP_BY,
|
||||
INVALID_VERTICAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
|
||||
),
|
||||
).toThrow(/groupByFieldMetadataIdX/);
|
||||
).toThrow(/primaryAxisGroupByFieldMetadataId/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HORIZONTAL_BAR graph', () => {
|
||||
it('should validate full horizontal bar graph configuration', () => {
|
||||
const result = validateAndTransformWidgetConfiguration(
|
||||
WidgetType.GRAPH,
|
||||
TEST_HORIZONTAL_BAR_CHART_CONFIG,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject(TEST_HORIZONTAL_BAR_CHART_CONFIG);
|
||||
});
|
||||
|
||||
it('should validate minimal horizontal bar graph configuration', () => {
|
||||
const result = validateAndTransformWidgetConfiguration(
|
||||
WidgetType.GRAPH,
|
||||
TEST_HORIZONTAL_BAR_CHART_CONFIG_MINIMAL,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject(TEST_HORIZONTAL_BAR_CHART_CONFIG_MINIMAL);
|
||||
});
|
||||
|
||||
it('should throw error for partial horizontal bar graph configuration with missing required fields', () => {
|
||||
expect(() =>
|
||||
validateAndTransformWidgetConfiguration(
|
||||
WidgetType.GRAPH,
|
||||
INVALID_HORIZONTAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
|
||||
),
|
||||
).toThrow(/primaryAxisGroupByFieldMetadataId/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@ const validateGraphConfiguration = (
|
||||
}
|
||||
|
||||
switch (graphType) {
|
||||
case GraphType.BAR: {
|
||||
case GraphType.VERTICAL_BAR:
|
||||
case GraphType.HORIZONTAL_BAR: {
|
||||
const instance = plainToInstance(BarChartConfigurationDTO, configuration);
|
||||
|
||||
const errors = validateSync(instance, {
|
||||
|
||||
+20
-19
@@ -2,6 +2,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { AxisNameDisplay } from 'src/engine/core-modules/page-layout/enums/axis-name-display.enum';
|
||||
import { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
|
||||
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { PAGE_LAYOUT_TAB_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/page-layout-tab-seeds.constant';
|
||||
@@ -142,8 +143,8 @@ export const getPageLayoutWidgetDataSeeds = (
|
||||
graphType: 'LINE',
|
||||
aggregateFieldMetadataId: opportunityAmountFieldId,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
groupByFieldMetadataIdX: opportunityCloseDateFieldId,
|
||||
orderByX: 'FIELD_ASC',
|
||||
primaryAxisGroupByFieldMetadataId: opportunityCloseDateFieldId,
|
||||
primaryAxisOrderBy: 'FIELD_ASC',
|
||||
axisNameDisplay: AxisNameDisplay.NONE,
|
||||
displayDataLabel: false,
|
||||
}
|
||||
@@ -167,12 +168,12 @@ export const getPageLayoutWidgetDataSeeds = (
|
||||
isDefined(opportunityCloseDateFieldId) &&
|
||||
isDefined(opportunityStageFieldId)
|
||||
? {
|
||||
graphType: 'BAR',
|
||||
graphType: GraphType.VERTICAL_BAR,
|
||||
aggregateFieldMetadataId: opportunityAmountFieldId,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
groupByFieldMetadataIdX: opportunityCloseDateFieldId,
|
||||
groupByFieldMetadataIdY: opportunityStageFieldId,
|
||||
orderByX: 'FIELD_ASC',
|
||||
primaryAxisGroupByFieldMetadataId: opportunityCloseDateFieldId,
|
||||
secondaryAxisGroupByFieldMetadataId: opportunityStageFieldId,
|
||||
primaryAxisOrderBy: 'FIELD_ASC',
|
||||
axisNameDisplay: AxisNameDisplay.BOTH,
|
||||
displayDataLabel: false,
|
||||
}
|
||||
@@ -196,11 +197,11 @@ export const getPageLayoutWidgetDataSeeds = (
|
||||
configuration:
|
||||
isDefined(rocketIdFieldId) && isDefined(rocketCreatedAtFieldId)
|
||||
? {
|
||||
graphType: 'BAR',
|
||||
graphType: GraphType.VERTICAL_BAR,
|
||||
aggregateFieldMetadataId: rocketIdFieldId,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
groupByFieldMetadataIdX: rocketCreatedAtFieldId,
|
||||
orderByX: 'FIELD_ASC',
|
||||
primaryAxisGroupByFieldMetadataId: rocketCreatedAtFieldId,
|
||||
primaryAxisOrderBy: 'FIELD_ASC',
|
||||
axisNameDisplay: AxisNameDisplay.NONE,
|
||||
displayDataLabel: false,
|
||||
}
|
||||
@@ -271,8 +272,8 @@ export const getPageLayoutWidgetDataSeeds = (
|
||||
graphType: 'LINE',
|
||||
aggregateFieldMetadataId: companyIdFieldId,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
groupByFieldMetadataIdX: companyCreatedAtFieldId,
|
||||
orderByX: 'FIELD_ASC',
|
||||
primaryAxisGroupByFieldMetadataId: companyCreatedAtFieldId,
|
||||
primaryAxisOrderBy: 'FIELD_ASC',
|
||||
axisNameDisplay: AxisNameDisplay.NONE,
|
||||
displayDataLabel: false,
|
||||
}
|
||||
@@ -296,13 +297,13 @@ export const getPageLayoutWidgetDataSeeds = (
|
||||
isDefined(companyEmployeesFieldId) &&
|
||||
isDefined(companyAddressFieldId)
|
||||
? {
|
||||
graphType: 'BAR',
|
||||
graphType: GraphType.VERTICAL_BAR,
|
||||
aggregateFieldMetadataId: companyIdFieldId,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
groupByFieldMetadataIdX: companyEmployeesFieldId,
|
||||
groupByFieldMetadataIdY: companyAddressFieldId,
|
||||
groupBySubFieldNameY: 'addressCity',
|
||||
orderByX: 'FIELD_ASC',
|
||||
primaryAxisGroupByFieldMetadataId: companyEmployeesFieldId,
|
||||
secondaryAxisGroupByFieldMetadataId: companyAddressFieldId,
|
||||
secondaryAxisGroupBySubFieldName: 'addressCity',
|
||||
primaryAxisOrderBy: 'FIELD_ASC',
|
||||
axisNameDisplay: AxisNameDisplay.BOTH,
|
||||
displayDataLabel: false,
|
||||
}
|
||||
@@ -463,11 +464,11 @@ export const getPageLayoutWidgetDataSeeds = (
|
||||
configuration:
|
||||
isDefined(personIdFieldId) && isDefined(personCityFieldId)
|
||||
? {
|
||||
graphType: 'BAR',
|
||||
graphType: GraphType.VERTICAL_BAR,
|
||||
aggregateFieldMetadataId: personIdFieldId,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
groupByFieldMetadataIdX: personCityFieldId,
|
||||
orderByX: 'VALUE_DESC',
|
||||
primaryAxisGroupByFieldMetadataId: personCityFieldId,
|
||||
primaryAxisOrderBy: 'VALUE_DESC',
|
||||
axisNameDisplay: AxisNameDisplay.NONE,
|
||||
displayDataLabel: false,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user