Create a feature flag for dashboards v2 (#15601)

Prevent users from creating v2 chart types via the api.
Only created unit tests and not integration tests (since it's not that
important, and the v2 will be released soon), but tested via the api
playground.
This commit is contained in:
Raphaël Bosi
2025-11-04 16:29:08 +01:00
committed by GitHub
parent 59f3f03539
commit 05a28a8ec2
15 changed files with 273 additions and 235 deletions
@@ -15,4 +15,5 @@ export enum FeatureFlagKey {
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_WORKFLOW_RUN_STOPPAGE_ENABLED = 'IS_WORKFLOW_RUN_STOPPAGE_ENABLED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { PageLayoutTabController } from 'src/engine/core-modules/page-layout/controllers/page-layout-tab.controller';
import { PageLayoutWidgetController } from 'src/engine/core-modules/page-layout/controllers/page-layout-widget.controller';
import { PageLayoutController } from 'src/engine/core-modules/page-layout/controllers/page-layout.controller';
@@ -24,6 +25,7 @@ import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
PageLayoutWidgetEntity,
]),
TwentyORMModule,
FeatureFlagModule,
],
controllers: [
PageLayoutController,
@@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { IsNull, type Repository } from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
import {
@@ -64,6 +65,12 @@ describe('PageLayoutWidgetService', () => {
findByIdOrThrow: jest.fn(),
},
},
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn(),
},
},
],
}).compile();
@@ -5,6 +5,8 @@ import { isDefined } from 'twenty-shared/utils';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { CreatePageLayoutWidgetInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-widget.input';
import { UpdatePageLayoutWidgetInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget.input';
import { WidgetConfigurationInterface } from 'src/engine/core-modules/page-layout/dtos/widget-configuration.interface';
@@ -29,6 +31,7 @@ export class PageLayoutWidgetService {
@InjectRepository(PageLayoutWidgetEntity)
private readonly pageLayoutWidgetRepository: Repository<PageLayoutWidgetEntity>,
private readonly pageLayoutTabService: PageLayoutTabService,
private readonly featureFlagService: FeatureFlagService,
) {}
private getPageLayoutWidgetRepository(
@@ -131,11 +134,18 @@ export class PageLayoutWidgetService {
let validatedConfig: WidgetConfigurationInterface | null = null;
if (pageLayoutWidgetData.configuration && pageLayoutWidgetData.type) {
try {
validatedConfig = validateAndTransformWidgetConfiguration(
pageLayoutWidgetData.type,
pageLayoutWidgetData.configuration,
const isDashboardV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DASHBOARD_V2_ENABLED,
workspaceId,
);
try {
validatedConfig = validateAndTransformWidgetConfiguration({
type: pageLayoutWidgetData.type,
configuration: pageLayoutWidgetData.configuration,
isDashboardV2Enabled,
});
} catch (error) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
@@ -228,11 +238,18 @@ export class PageLayoutWidgetService {
const titleForError = updateData.title ?? existingWidget.title;
if (typeForValidation) {
try {
validatedConfig = validateAndTransformWidgetConfiguration(
typeForValidation,
updateData.configuration,
const isDashboardV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DASHBOARD_V2_ENABLED,
workspaceId,
);
try {
validatedConfig = validateAndTransformWidgetConfiguration({
type: typeForValidation,
configuration: updateData.configuration,
isDashboardV2Enabled,
});
} catch (error) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
@@ -1,43 +0,0 @@
import {
INVALID_IFRAME_CONFIG_BAD_URL,
TEST_IFRAME_CONFIG,
TEST_NUMBER_CHART_CONFIG,
} from 'test/integration/constants/widget-configuration-test-data.constants';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
import { isWidgetConfigurationValid } from 'src/engine/core-modules/page-layout/utils/is-widget-configuration-valid.util';
describe('isWidgetConfigurationValid', () => {
it('should return true for valid configuration', () => {
const result = isWidgetConfigurationValid(
WidgetType.IFRAME,
TEST_IFRAME_CONFIG,
);
expect(result).toBe(true);
});
it('should return false for invalid configuration', () => {
const result = isWidgetConfigurationValid(
WidgetType.IFRAME,
INVALID_IFRAME_CONFIG_BAD_URL,
);
expect(result).toBe(false);
});
it('should return false for null configuration', () => {
const result = isWidgetConfigurationValid(WidgetType.IFRAME, null);
expect(result).toBe(false);
});
it('should return false for unsupported widget type', () => {
const result = isWidgetConfigurationValid(
'UNSUPPORTED' as WidgetType,
TEST_NUMBER_CHART_CONFIG,
);
expect(result).toBe(false);
});
});
@@ -6,16 +6,13 @@ import {
INVALID_NUMBER_CHART_CONFIG_MISSING_FIELDS,
INVALID_VERTICAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
TEST_GAUGE_CHART_CONFIG,
TEST_GAUGE_CHART_CONFIG_MINIMAL,
TEST_HORIZONTAL_BAR_CHART_CONFIG,
TEST_HORIZONTAL_BAR_CHART_CONFIG_MINIMAL,
TEST_IFRAME_CONFIG,
TEST_LINE_CHART_CONFIG,
TEST_LINE_CHART_CONFIG_MINIMAL,
TEST_NUMBER_CHART_CONFIG,
TEST_NUMBER_CHART_CONFIG_MINIMAL,
TEST_PIE_CHART_CONFIG,
TEST_PIE_CHART_CONFIG_MINIMAL,
TEST_VERTICAL_BAR_CHART_CONFIG,
TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL,
} from 'test/integration/constants/widget-configuration-test-data.constants';
@@ -26,29 +23,32 @@ import { validateAndTransformWidgetConfiguration } from 'src/engine/core-modules
describe('validateAndTransformWidgetConfiguration', () => {
describe('IFRAME widget', () => {
it('should validate and transform valid iframe configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.IFRAME,
TEST_IFRAME_CONFIG,
);
const result = validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: TEST_IFRAME_CONFIG,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_IFRAME_CONFIG);
});
it('should throw error for invalid URL', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.IFRAME,
INVALID_IFRAME_CONFIG_BAD_URL,
),
validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: INVALID_IFRAME_CONFIG_BAD_URL,
isDashboardV2Enabled: false,
}),
).toThrow(/url must be a URL address/);
});
it('should throw error for empty URL', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.IFRAME,
INVALID_IFRAME_CONFIG_EMPTY_URL,
),
validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: INVALID_IFRAME_CONFIG_EMPTY_URL,
isDashboardV2Enabled: false,
}),
).toThrow(/url must be a URL address/);
});
});
@@ -56,170 +56,121 @@ describe('validateAndTransformWidgetConfiguration', () => {
describe('GRAPH widget', () => {
describe('NUMBER graph', () => {
it('should validate full number graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_NUMBER_CHART_CONFIG,
);
const result = validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_NUMBER_CHART_CONFIG,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_NUMBER_CHART_CONFIG);
});
it('should validate minimal number graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_NUMBER_CHART_CONFIG_MINIMAL,
);
const result = validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_NUMBER_CHART_CONFIG_MINIMAL,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_NUMBER_CHART_CONFIG_MINIMAL);
});
it('should throw error for partial number graph configuration with missing required fields', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
INVALID_NUMBER_CHART_CONFIG_MISSING_FIELDS,
),
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: INVALID_NUMBER_CHART_CONFIG_MISSING_FIELDS,
isDashboardV2Enabled: false,
}),
).toThrow(/aggregateFieldMetadataId.*aggregateOperation/);
});
it('should throw error for invalid UUID', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
),
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
isDashboardV2Enabled: false,
}),
).toThrow(/aggregateFieldMetadataId must be a UUID/);
});
});
describe('VERTICAL_BAR graph', () => {
it('should validate full vertical bar graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_VERTICAL_BAR_CHART_CONFIG,
);
const result = validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_VERTICAL_BAR_CHART_CONFIG,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_VERTICAL_BAR_CHART_CONFIG);
});
it('should validate minimal vertical bar graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL,
);
const result = validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL);
});
it('should throw error for partial vertical bar graph configuration with missing required fields', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
INVALID_VERTICAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
),
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: INVALID_VERTICAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
isDashboardV2Enabled: false,
}),
).toThrow(/primaryAxisGroupByFieldMetadataId/);
});
});
describe('HORIZONTAL_BAR graph', () => {
it('should validate full horizontal bar graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_HORIZONTAL_BAR_CHART_CONFIG,
);
const result = validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_HORIZONTAL_BAR_CHART_CONFIG,
isDashboardV2Enabled: false,
});
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,
);
const result = validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_HORIZONTAL_BAR_CHART_CONFIG_MINIMAL,
isDashboardV2Enabled: false,
});
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,
),
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: INVALID_HORIZONTAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
isDashboardV2Enabled: false,
}),
).toThrow(/primaryAxisGroupByFieldMetadataId/);
});
});
describe('LINE graph', () => {
it('should validate full line graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_LINE_CHART_CONFIG,
);
expect(result).toMatchObject(TEST_LINE_CHART_CONFIG);
});
it('should validate minimal line graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_LINE_CHART_CONFIG_MINIMAL,
);
expect(result).toMatchObject(TEST_LINE_CHART_CONFIG_MINIMAL);
});
});
describe('PIE graph', () => {
it('should validate full pie graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_PIE_CHART_CONFIG,
);
expect(result).toMatchObject(TEST_PIE_CHART_CONFIG);
});
it('should validate minimal pie graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_PIE_CHART_CONFIG_MINIMAL,
);
expect(result).toMatchObject(TEST_PIE_CHART_CONFIG_MINIMAL);
});
});
describe('GAUGE graph', () => {
it('should validate full gauge graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_GAUGE_CHART_CONFIG,
);
expect(result).toMatchObject(TEST_GAUGE_CHART_CONFIG);
});
it('should validate minimal gauge graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_GAUGE_CHART_CONFIG_MINIMAL,
);
expect(result).toMatchObject(TEST_GAUGE_CHART_CONFIG_MINIMAL);
});
});
it('should return null for unsupported graph type', () => {
const configuration = {
graphType: 'UNSUPPORTED',
viewId: '550e8400-e29b-41d4-a716-446655440000',
};
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
configuration,
);
const result = validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: configuration,
isDashboardV2Enabled: false,
});
expect(result).toBeNull();
});
@@ -229,10 +180,11 @@ describe('validateAndTransformWidgetConfiguration', () => {
viewId: '550e8400-e29b-41d4-a716-446655440000',
};
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
configuration,
);
const result = validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: configuration,
isDashboardV2Enabled: false,
});
expect(result).toBeNull();
});
@@ -241,29 +193,42 @@ describe('validateAndTransformWidgetConfiguration', () => {
describe('Edge cases', () => {
it('should throw error for null configuration', () => {
expect(() =>
validateAndTransformWidgetConfiguration(WidgetType.IFRAME, null),
validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: null,
isDashboardV2Enabled: false,
}),
).toThrow('Invalid configuration: not an object');
});
it('should throw error for undefined configuration', () => {
expect(() =>
validateAndTransformWidgetConfiguration(WidgetType.IFRAME, undefined),
validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: undefined,
isDashboardV2Enabled: false,
}),
).toThrow('Invalid configuration: not an object');
});
it('should throw error for non-object configuration', () => {
expect(() =>
validateAndTransformWidgetConfiguration(WidgetType.IFRAME, 'string'),
validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: 'string',
isDashboardV2Enabled: false,
}),
).toThrow('Invalid configuration: not an object');
});
it('should return null for unsupported widget type', () => {
const configuration = { someField: 'value' };
const result = validateAndTransformWidgetConfiguration(
'UNSUPPORTED' as WidgetType,
configuration,
);
const result = validateAndTransformWidgetConfiguration({
type: 'UNSUPPORTED' as WidgetType,
configuration: configuration,
isDashboardV2Enabled: false,
});
expect(result).toBeNull();
});
@@ -272,11 +237,70 @@ describe('validateAndTransformWidgetConfiguration', () => {
describe('Error messages', () => {
it('should include validation details in error message', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
),
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
isDashboardV2Enabled: false,
}),
).toThrow(/aggregateFieldMetadataId must be a UUID/);
});
});
describe('Feature flags', () => {
it('should throw error for unsupported graph type', () => {
expect(() =>
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_PIE_CHART_CONFIG,
isDashboardV2Enabled: false,
}),
).toThrow(/IS_DASHBOARD_V2_ENABLED feature flag/);
expect(() =>
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_LINE_CHART_CONFIG,
isDashboardV2Enabled: false,
}),
).toThrow(/IS_DASHBOARD_V2_ENABLED feature flag/);
expect(() =>
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_GAUGE_CHART_CONFIG,
isDashboardV2Enabled: false,
}),
).toThrow(/IS_DASHBOARD_V2_ENABLED feature flag/);
});
it('should not throw error when IS_DASHBOARD_V2_ENABLED feature flag is enabled', () => {
expect(() =>
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_PIE_CHART_CONFIG,
isDashboardV2Enabled: true,
}),
).not.toThrow();
});
it('should not throw error when IS_DASHBOARD_V2_ENABLED feature flag is enabled', () => {
expect(() =>
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_LINE_CHART_CONFIG,
isDashboardV2Enabled: true,
}),
).not.toThrow();
});
it('should not throw error when IS_DASHBOARD_V2_ENABLED feature flag is enabled', () => {
expect(() =>
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_GAUGE_CHART_CONFIG,
isDashboardV2Enabled: true,
}),
).not.toThrow();
});
});
});
@@ -1,18 +0,0 @@
import { type WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
import { validateAndTransformWidgetConfiguration } from 'src/engine/core-modules/page-layout/utils/validate-and-transform-widget-configuration.util';
export const isWidgetConfigurationValid = (
type: WidgetType,
configuration: unknown,
): boolean => {
try {
const validatedConfig = validateAndTransformWidgetConfiguration(
type,
configuration,
);
return validatedConfig !== null;
} catch {
return false;
}
};
@@ -23,15 +23,27 @@ const formatValidationErrors = (errors: ValidationError[]): string => {
.join('; ');
};
const validateGraphConfiguration = (
configuration: Record<string, unknown>,
): WidgetConfigurationInterface | null => {
const validateGraphConfiguration = ({
configuration,
isDashboardV2Enabled,
}: {
configuration: Record<string, unknown>;
isDashboardV2Enabled: boolean;
}): WidgetConfigurationInterface | null => {
const graphType = configuration.graphType as GraphType;
if (!graphType || !Object.values(GraphType).includes(graphType)) {
return null;
}
const v2ChartTypes = [GraphType.PIE, GraphType.LINE, GraphType.GAUGE];
if (v2ChartTypes.includes(graphType) && !isDashboardV2Enabled) {
throw new Error(
`Chart type ${graphType} requires IS_DASHBOARD_V2_ENABLED feature flag`,
);
}
switch (graphType) {
case GraphType.VERTICAL_BAR:
case GraphType.HORIZONTAL_BAR: {
@@ -135,10 +147,15 @@ const validateIframeConfiguration = (
return instance;
};
export const validateAndTransformWidgetConfiguration = (
type: WidgetType,
configuration: unknown,
): WidgetConfigurationInterface | null => {
export const validateAndTransformWidgetConfiguration = ({
type,
configuration,
isDashboardV2Enabled,
}: {
type: WidgetType;
configuration: unknown;
isDashboardV2Enabled: boolean;
}): WidgetConfigurationInterface | null => {
if (!configuration || typeof configuration !== 'object') {
throw new Error('Invalid configuration: not an object');
}
@@ -146,9 +163,10 @@ export const validateAndTransformWidgetConfiguration = (
try {
switch (type) {
case WidgetType.GRAPH:
return validateGraphConfiguration(
configuration as Record<string, unknown>,
);
return validateGraphConfiguration({
configuration: configuration as Record<string, unknown>,
isDashboardV2Enabled,
});
case WidgetType.IFRAME:
return validateIframeConfiguration(configuration);
default: