feat(sdk): validate graph page-layout widgets at build time (#22559)

When an app defines a graph widget (aggregate, pie, bar or line chart),
the built manifest can carry the wrong key and the server rejects it at
sync time with a confusing "aggregate field is required" error.

The SDK type already requires
`aggregateFieldMetadataUniversalIdentifier` and renames the raw
`aggregateFieldMetadataId` at compile time. But the manifest build runs
esbuild with no type checking, so a wrong or missing key slips through
and only fails later on the server.

This adds a build-time check that mirrors the server validator, with a
hint pointing at the right key when the raw one was used. It is
non-breaking since correctly authored apps already use the universal
key.

Tests: unit tests on the validator, plus a real graph widget added to
the rich-app fixture so the integration and e2e suites cover the happy
path.
This commit is contained in:
Charles Bochet
2026-07-08 16:25:47 +02:00
committed by GitHub
parent ad74f75e6a
commit d746909184
6 changed files with 223 additions and 3 deletions
@@ -1,8 +1,11 @@
import {
AggregateOperations,
definePageLayoutTab,
PageLayoutTabLayoutMode,
} from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default definePageLayoutTab({
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000010',
pageLayoutUniversalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000020',
@@ -21,5 +24,17 @@ export default definePageLayoutTab({
'370ae182-743f-4ecb-b625-7ac48e21f0e5',
},
},
{
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000012',
title: 'Total Priority',
type: 'GRAPH',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
'7b57bd63-5a4c-46ca-9d52-42c8f02d1df6',
aggregateOperation: AggregateOperations.SUM,
},
},
],
});
@@ -2,6 +2,7 @@ import { FieldType } from '@/sdk/define';
import type { Manifest } from 'twenty-shared/application';
import { SystemPermissionFlag } from 'twenty-shared/constants';
import {
AggregateOperations,
FieldMetadataType,
NavigationMenuItemType,
PageLayoutTabLayoutMode,
@@ -34,6 +35,18 @@ export const EXPECTED_MANIFEST: Manifest = {
'370ae182-743f-4ecb-b625-7ac48e21f0e5',
},
},
{
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000012',
title: 'Total Priority',
type: 'GRAPH',
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
'7b57bd63-5a4c-46ca-9d52-42c8f02d1df6',
aggregateOperation: AggregateOperations.SUM,
},
},
],
},
],
@@ -2,8 +2,14 @@ import {
type ApplicationManifest,
type FieldManifest,
type Manifest,
type PageLayoutTabManifest,
type PageLayoutWidgetManifest,
} from 'twenty-shared/application';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import {
AggregateOperations,
FieldMetadataType,
RelationType,
} from 'twenty-shared/types';
import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate';
const validApplication: ApplicationManifest = {
@@ -494,4 +500,103 @@ describe('manifestValidate', () => {
expect(versionErrors).toHaveLength(1);
});
});
describe('graph widget validation', () => {
const makeGraphWidgetTab = (
configuration: PageLayoutWidgetManifest['configuration'],
): PageLayoutTabManifest => ({
universalIdentifier: 'b0a5f0f2-6c2e-4d1c-9d0b-2f8a4c3e1a01',
title: 'Dashboard',
position: 0,
widgets: [
{
universalIdentifier: 'b0a5f0f2-6c2e-4d1c-9d0b-2f8a4c3e1a02',
title: 'Total opportunities',
type: 'GRAPH',
configuration,
},
],
});
it('should pass when a graph widget has aggregateFieldMetadataUniversalIdentifier', () => {
const result = manifestValidate({
...validManifest,
pageLayoutTabs: [
makeGraphWidgetTab({
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
'b0a5f0f2-6c2e-4d1c-9d0b-2f8a4c3e1a03',
aggregateOperation: AggregateOperations.COUNT,
}),
],
});
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should error when a graph widget is missing the aggregate field identifier', () => {
const result = manifestValidate({
...validManifest,
pageLayoutTabs: [
makeGraphWidgetTab({
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier: null,
aggregateOperation: AggregateOperations.COUNT,
}),
],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.stringContaining(
'is missing aggregateFieldMetadataUniversalIdentifier',
),
);
});
it('should hint at the correct key when the raw aggregateFieldMetadataId was used', () => {
const configurationWithRawKey = {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataId: 'b0a5f0f2-6c2e-4d1c-9d0b-2f8a4c3e1a03',
aggregateOperation: AggregateOperations.COUNT,
} as unknown as PageLayoutWidgetManifest['configuration'];
const result = manifestValidate({
...validManifest,
pageLayoutTabs: [makeGraphWidgetTab(configurationWithRawKey)],
});
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.stringContaining(
'not "aggregateFieldMetadataId"',
),
);
});
it('should ignore non-graph widgets that have no aggregate field', () => {
const result = manifestValidate({
...validManifest,
pageLayoutTabs: [
makeGraphWidgetTab({ configurationType: 'TIMELINE' }),
],
});
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should not crash on a widget with a missing configuration', () => {
const nullConfiguration =
null as unknown as PageLayoutWidgetManifest['configuration'];
const result = manifestValidate({
...validManifest,
pageLayoutTabs: [makeGraphWidgetTab(nullConfiguration)],
});
expect(result.isValid).toBe(true);
});
});
});
@@ -1,7 +1,19 @@
import { isNonEmptyString } from '@sniptt/guards';
import { validate as uuidValidate, version as uuidVersion } from 'uuid';
import { type FieldManifest, type Manifest } from 'twenty-shared/application';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import {
type FieldManifest,
type Manifest,
type PageLayoutWidgetManifest,
} from 'twenty-shared/application';
import {
FieldMetadataType,
GRAPH_WIDGET_CONFIGURATION_TYPES,
type GraphWidgetConfigurationType,
type PageLayoutWidgetUniversalConfiguration,
RelationType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const MIN_UUID_VERSION = 4;
@@ -15,6 +27,22 @@ const VALID_RELATION_TYPES: string[] = [
RelationType.ONE_TO_MANY,
];
const RAW_AGGREGATE_FIELD_METADATA_ID_KEY = 'aggregateFieldMetadataId';
type GraphPageLayoutWidgetUniversalConfiguration = Extract<
PageLayoutWidgetUniversalConfiguration,
{ configurationType: GraphWidgetConfigurationType }
>;
const isGraphWidgetConfiguration = (
configuration: PageLayoutWidgetUniversalConfiguration | null | undefined,
): configuration is GraphPageLayoutWidgetUniversalConfiguration =>
isDefined(configuration) &&
GRAPH_WIDGET_CONFIGURATION_TYPES.some(
(configurationType) =>
configurationType === configuration.configurationType,
);
const extractDuplicates = (values: string[]): string[] => {
const seen = new Set<string>();
const duplicates = new Set<string>();
@@ -102,6 +130,50 @@ const validateRelationFields = (
return errors;
};
const collectPageLayoutWidgets = (
manifest: Pick<Manifest, 'pageLayouts' | 'pageLayoutTabs'>,
): PageLayoutWidgetManifest[] => {
const widgetsFromPageLayouts = manifest.pageLayouts.flatMap(
(pageLayout) => pageLayout.tabs?.flatMap((tab) => tab.widgets ?? []) ?? [],
);
const widgetsFromStandaloneTabs = manifest.pageLayoutTabs.flatMap(
(tab) => tab.widgets ?? [],
);
return [...widgetsFromPageLayouts, ...widgetsFromStandaloneTabs];
};
const validateGraphWidgets = (
widgets: PageLayoutWidgetManifest[],
): string[] => {
const errors: string[] = [];
for (const widget of widgets) {
const configuration = widget.configuration;
if (!isGraphWidgetConfiguration(configuration)) {
continue;
}
if (
!isNonEmptyString(configuration.aggregateFieldMetadataUniversalIdentifier)
) {
const usedRawKey = RAW_AGGREGATE_FIELD_METADATA_ID_KEY in configuration;
const hint = usedRawKey
? ` Reference the aggregate field with "aggregateFieldMetadataUniversalIdentifier" (not "${RAW_AGGREGATE_FIELD_METADATA_ID_KEY}").`
: '';
errors.push(
`Graph widget "${widget.title}" is missing aggregateFieldMetadataUniversalIdentifier.${hint}`,
);
}
}
return errors;
};
const invalidUniversalIdentifierVersions = (
identifiers: string[],
): string[] => {
@@ -163,5 +235,7 @@ export const manifestValidate = (manifest: Manifest) => {
errors.push(...validateRelationFields(allFields));
errors.push(...validateGraphWidgets(collectPageLayoutWidgets(manifest)));
return { errors, warnings, isValid: errors.length === 0 };
};
@@ -181,6 +181,8 @@ export type {
ChartFilter,
UniversalChartFilter,
} from './page-layout/chart-filter.type';
export type { GraphWidgetConfigurationType } from './page-layout/graph-widget-configuration-type';
export { GRAPH_WIDGET_CONFIGURATION_TYPES } from './page-layout/graph-widget-configuration-type';
export type { GridPosition } from './page-layout/grid-position.type';
export type {
AggregateChartConfiguration,
@@ -0,0 +1,11 @@
import { type PageLayoutWidgetConfiguration } from './page-layout-widget-configuration.type';
export const GRAPH_WIDGET_CONFIGURATION_TYPES = [
'AGGREGATE_CHART',
'PIE_CHART',
'BAR_CHART',
'LINE_CHART',
] as const satisfies readonly PageLayoutWidgetConfiguration['configurationType'][];
export type GraphWidgetConfigurationType =
(typeof GRAPH_WIDGET_CONFIGURATION_TYPES)[number];