fixes: loosen up front validation, add resolveEntityRelationUniversalIdentifiers to update and restore (#18015)

closes https://github.com/twentyhq/private-issues/issues/419
This commit is contained in:
nitin
2026-02-18 16:05:08 +05:30
committed by GitHub
parent 08a3d983cb
commit 477fbc0865
14 changed files with 97 additions and 364 deletions
@@ -4,6 +4,7 @@ import {
} from '~/generated-metadata/graphql';
export type BarLineChartConvertibleFields = {
aggregateFieldMetadataId?: string;
primaryAxisGroupByFieldMetadataId?: string;
primaryAxisGroupBySubFieldName?: string | null;
primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null;
@@ -4,6 +4,7 @@ import {
} from '~/generated-metadata/graphql';
export type PieChartConvertibleFields = {
aggregateFieldMetadataId?: string;
groupByFieldMetadataId?: string;
groupBySubFieldName?: string | null;
dateGranularity?: ObjectRecordGroupByDateGranularity | null;
@@ -19,6 +19,9 @@ describe('convertBarOrLineChartConfigToPieChart', () => {
TEST_BAR_CHART_CONFIGURATION.primaryAxisGroupBySubFieldName,
dateGranularity: TEST_BAR_CHART_CONFIGURATION.primaryAxisDateGranularity,
orderBy: TEST_BAR_CHART_CONFIGURATION.primaryAxisOrderBy,
splitMultiValueFields: TEST_BAR_CHART_CONFIGURATION.splitMultiValueFields,
aggregateFieldMetadataId:
TEST_BAR_CHART_CONFIGURATION.aggregateFieldMetadataId,
});
});
@@ -34,6 +37,10 @@ describe('convertBarOrLineChartConfigToPieChart', () => {
TEST_LINE_CHART_CONFIGURATION.primaryAxisGroupBySubFieldName,
dateGranularity: TEST_LINE_CHART_CONFIGURATION.primaryAxisDateGranularity,
orderBy: TEST_LINE_CHART_CONFIGURATION.primaryAxisOrderBy,
splitMultiValueFields:
TEST_LINE_CHART_CONFIGURATION.splitMultiValueFields,
aggregateFieldMetadataId:
TEST_LINE_CHART_CONFIGURATION.aggregateFieldMetadataId,
});
});
@@ -12,12 +12,15 @@ describe('convertPieChartConfigToBarOrLineChart', () => {
);
expect(result).toEqual({
aggregateFieldMetadataId:
TEST_PIE_CHART_CONFIGURATION.aggregateFieldMetadataId,
primaryAxisGroupByFieldMetadataId:
TEST_PIE_CHART_CONFIGURATION.groupByFieldMetadataId,
primaryAxisGroupBySubFieldName:
TEST_PIE_CHART_CONFIGURATION.groupBySubFieldName,
primaryAxisDateGranularity: TEST_PIE_CHART_CONFIGURATION.dateGranularity,
primaryAxisOrderBy: TEST_PIE_CHART_CONFIGURATION.orderBy,
splitMultiValueFields: TEST_PIE_CHART_CONFIGURATION.splitMultiValueFields,
});
});
@@ -33,10 +36,13 @@ describe('convertPieChartConfigToBarOrLineChart', () => {
const result = convertPieChartConfigToBarOrLineChart(minimalPieConfig);
expect(result).toEqual({
aggregateFieldMetadataId:
TEST_PIE_CHART_CONFIGURATION.aggregateFieldMetadataId,
primaryAxisGroupByFieldMetadataId: undefined,
primaryAxisGroupBySubFieldName: null,
primaryAxisDateGranularity: null,
primaryAxisOrderBy: null,
splitMultiValueFields: TEST_PIE_CHART_CONFIGURATION.splitMultiValueFields,
});
});
@@ -15,6 +15,7 @@ export const convertBarOrLineChartConfigToPieChart = (
}
return {
aggregateFieldMetadataId: configuration.aggregateFieldMetadataId,
groupByFieldMetadataId: configuration.primaryAxisGroupByFieldMetadataId,
groupBySubFieldName: configuration.primaryAxisGroupBySubFieldName,
dateGranularity: configuration.primaryAxisDateGranularity,
@@ -10,6 +10,7 @@ export const convertPieChartConfigToBarOrLineChart = (
}
return {
aggregateFieldMetadataId: configuration.aggregateFieldMetadataId,
primaryAxisGroupByFieldMetadataId: configuration.groupByFieldMetadataId,
primaryAxisGroupBySubFieldName: configuration.groupBySubFieldName,
primaryAxisDateGranularity: configuration.dateGranularity,
@@ -179,6 +179,11 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
{hasAccess ? (
<ErrorBoundary
FallbackComponent={PageLayoutWidgetInvalidConfigDisplay}
resetKeys={[
widget.id,
widget.configuration,
widget.objectMetadataId,
]}
>
<WidgetContentRenderer widget={widget} />
</ErrorBoundary>
@@ -1,14 +1,10 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { PageLayoutWidgetNoDataDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay';
import { WidgetSkeletonLoader } from '@/page-layout/widgets/components/WidgetSkeletonLoader';
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 { GraphWidgetPieChartRenderer } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer';
import { areChartConfigurationFieldsValidForQuery } from '@/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery';
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
import { lazy, Suspense } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
const GraphWidgetGaugeChart = lazy(() =>
@@ -19,26 +15,9 @@ const GraphWidgetGaugeChart = lazy(() =>
})),
);
export type GraphWidgetProps = {
objectMetadataId: string;
};
export const GraphWidget = ({ objectMetadataId }: GraphWidgetProps) => {
export const GraphWidget = () => {
const widget = useCurrentWidget();
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
const hasValidConfiguration = areChartConfigurationFieldsValidForQuery(
widget.configuration,
objectMetadataItem,
);
if (!isDefined(widget.configuration) || !hasValidConfiguration) {
return <PageLayoutWidgetNoDataDisplay />;
}
const configurationType = widget.configuration?.configurationType;
switch (configurationType) {
@@ -1,6 +1,7 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { PageLayoutWidgetNoDataDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay';
import { GraphWidget } from '@/page-layout/widgets/graph/components/GraphWidget';
import { hasMinimalRequiredConfigForGraph } from '@/page-layout/widgets/graph/utils/hasMinimalRequiredConfigForGraph';
import { isDefined } from 'twenty-shared/utils';
type GraphWidgetRendererProps = {
@@ -8,13 +9,13 @@ type GraphWidgetRendererProps = {
};
export const GraphWidgetRenderer = ({ widget }: GraphWidgetRendererProps) => {
if (!isDefined(widget.configuration)) {
if (
!isDefined(widget.configuration) ||
!isDefined(widget.objectMetadataId) ||
!hasMinimalRequiredConfigForGraph(widget.configuration)
) {
return <PageLayoutWidgetNoDataDisplay />;
}
if (!isDefined(widget.objectMetadataId)) {
return <PageLayoutWidgetNoDataDisplay />;
}
return <GraphWidget objectMetadataId={widget.objectMetadataId} />;
return <GraphWidget />;
};
@@ -1,213 +0,0 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
AggregateOperations,
FieldMetadataType,
WidgetConfigurationType,
type AggregateChartConfiguration,
} from '~/generated-metadata/graphql';
import { areChartConfigurationFieldsValidForQuery } from '@/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery';
describe('areChartConfigurationFieldsValidForQuery', () => {
const createMockObjectMetadataItem = (
fields: Array<{
id: string;
type: FieldMetadataType;
options?: Array<{ value: string; label: string }>;
}>,
): ObjectMetadataItem =>
({
id: 'object-1',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: fields.map((f) => ({
id: f.id,
name: f.id,
type: f.type,
label: f.id,
options: f.options,
})),
}) as ObjectMetadataItem;
const createAggregateConfig = (ratioAggregateConfig?: {
fieldMetadataId: string;
optionValue: string;
}): AggregateChartConfiguration => ({
__typename: 'AggregateChartConfiguration',
configurationType: WidgetConfigurationType.AGGREGATE_CHART,
aggregateFieldMetadataId: 'aggregate-field',
aggregateOperation: AggregateOperations.COUNT,
ratioAggregateConfig: ratioAggregateConfig ?? null,
});
describe('AggregateChartConfiguration with ratioAggregateConfig', () => {
it('should return true when ratioAggregateConfig is undefined', () => {
const objectMetadataItem = createMockObjectMetadataItem([
{ id: 'aggregate-field', type: FieldMetadataType.NUMBER },
]);
const result = areChartConfigurationFieldsValidForQuery(
createAggregateConfig(undefined),
objectMetadataItem,
);
expect(result).toBe(true);
});
it('should return false when ratio field does not exist', () => {
const objectMetadataItem = createMockObjectMetadataItem([
{ id: 'aggregate-field', type: FieldMetadataType.NUMBER },
]);
const result = areChartConfigurationFieldsValidForQuery(
createAggregateConfig({
fieldMetadataId: 'non-existent-field',
optionValue: 'true',
}),
objectMetadataItem,
);
expect(result).toBe(false);
});
it('should return true for BOOLEAN field with "true" optionValue', () => {
const objectMetadataItem = createMockObjectMetadataItem([
{ id: 'aggregate-field', type: FieldMetadataType.NUMBER },
{ id: 'boolean-field', type: FieldMetadataType.BOOLEAN },
]);
const result = areChartConfigurationFieldsValidForQuery(
createAggregateConfig({
fieldMetadataId: 'boolean-field',
optionValue: 'true',
}),
objectMetadataItem,
);
expect(result).toBe(true);
});
it('should return true for BOOLEAN field with "false" optionValue', () => {
const objectMetadataItem = createMockObjectMetadataItem([
{ id: 'aggregate-field', type: FieldMetadataType.NUMBER },
{ id: 'boolean-field', type: FieldMetadataType.BOOLEAN },
]);
const result = areChartConfigurationFieldsValidForQuery(
createAggregateConfig({
fieldMetadataId: 'boolean-field',
optionValue: 'false',
}),
objectMetadataItem,
);
expect(result).toBe(true);
});
it('should return false for BOOLEAN field with invalid optionValue', () => {
const objectMetadataItem = createMockObjectMetadataItem([
{ id: 'aggregate-field', type: FieldMetadataType.NUMBER },
{ id: 'boolean-field', type: FieldMetadataType.BOOLEAN },
]);
const result = areChartConfigurationFieldsValidForQuery(
createAggregateConfig({
fieldMetadataId: 'boolean-field',
optionValue: 'invalid',
}),
objectMetadataItem,
);
expect(result).toBe(false);
});
it('should return true for SELECT field with valid option', () => {
const objectMetadataItem = createMockObjectMetadataItem([
{ id: 'aggregate-field', type: FieldMetadataType.NUMBER },
{
id: 'select-field',
type: FieldMetadataType.SELECT,
options: [
{ value: 'WON', label: 'Won' },
{ value: 'LOST', label: 'Lost' },
],
},
]);
const result = areChartConfigurationFieldsValidForQuery(
createAggregateConfig({
fieldMetadataId: 'select-field',
optionValue: 'WON',
}),
objectMetadataItem,
);
expect(result).toBe(true);
});
it('should return false for SELECT field with invalid option', () => {
const objectMetadataItem = createMockObjectMetadataItem([
{ id: 'aggregate-field', type: FieldMetadataType.NUMBER },
{
id: 'select-field',
type: FieldMetadataType.SELECT,
options: [
{ value: 'WON', label: 'Won' },
{ value: 'LOST', label: 'Lost' },
],
},
]);
const result = areChartConfigurationFieldsValidForQuery(
createAggregateConfig({
fieldMetadataId: 'select-field',
optionValue: 'INVALID_OPTION',
}),
objectMetadataItem,
);
expect(result).toBe(false);
});
it('should return true for MULTI_SELECT field with valid option', () => {
const objectMetadataItem = createMockObjectMetadataItem([
{ id: 'aggregate-field', type: FieldMetadataType.NUMBER },
{
id: 'multiselect-field',
type: FieldMetadataType.MULTI_SELECT,
options: [
{ value: 'urgent', label: 'Urgent' },
{ value: 'important', label: 'Important' },
],
},
]);
const result = areChartConfigurationFieldsValidForQuery(
createAggregateConfig({
fieldMetadataId: 'multiselect-field',
optionValue: 'urgent',
}),
objectMetadataItem,
);
expect(result).toBe(true);
});
it('should return false for unsupported field type with ratioConfig', () => {
const objectMetadataItem = createMockObjectMetadataItem([
{ id: 'aggregate-field', type: FieldMetadataType.NUMBER },
{ id: 'text-field', type: FieldMetadataType.TEXT },
]);
const result = areChartConfigurationFieldsValidForQuery(
createAggregateConfig({
fieldMetadataId: 'text-field',
optionValue: 'some-value',
}),
objectMetadataItem,
);
expect(result).toBe(false);
});
});
});
@@ -1,119 +0,0 @@
import { isString } from '@sniptt/guards';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { isDefined } from 'twenty-shared/utils';
import {
FieldMetadataType,
type RatioAggregateConfig,
} from '~/generated-metadata/graphql';
const fieldExists = (
fieldId: string | undefined | null,
objectMetadataItem: ObjectMetadataItem,
): boolean => {
if (!isDefined(fieldId)) {
return false;
}
return objectMetadataItem.fields.some((field) => field.id === fieldId);
};
const isRatioConfigValid = (
ratioConfig: RatioAggregateConfig | null | undefined,
objectMetadataItem: ObjectMetadataItem,
): boolean => {
if (!isDefined(ratioConfig)) {
return true;
}
const field = objectMetadataItem.fields.find(
(f) => f.id === ratioConfig.fieldMetadataId,
);
if (!isDefined(field)) {
return false;
}
if (field.type === FieldMetadataType.BOOLEAN) {
return (
ratioConfig.optionValue === 'true' || ratioConfig.optionValue === 'false'
);
}
if (
field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT
) {
const options = field.options ?? [];
return options.some((option) => option.value === ratioConfig.optionValue);
}
return false;
};
export const areChartConfigurationFieldsValidForQuery = (
configuration: PageLayoutWidget['configuration'],
objectMetadataItem?: ObjectMetadataItem | null,
): boolean => {
if (!isDefined(configuration)) {
return false;
}
if (!isDefined(objectMetadataItem) || !isDefined(objectMetadataItem.fields)) {
return false;
}
switch (configuration.__typename) {
case 'BarChartConfiguration':
case 'LineChartConfiguration':
return (
fieldExists(
configuration.aggregateFieldMetadataId,
objectMetadataItem,
) &&
fieldExists(
configuration.primaryAxisGroupByFieldMetadataId,
objectMetadataItem,
) &&
(!isDefined(configuration.secondaryAxisGroupByFieldMetadataId) ||
fieldExists(
configuration.secondaryAxisGroupByFieldMetadataId,
objectMetadataItem,
))
);
case 'PieChartConfiguration':
return (
fieldExists(
configuration.aggregateFieldMetadataId,
objectMetadataItem,
) &&
fieldExists(configuration.groupByFieldMetadataId, objectMetadataItem)
);
case 'AggregateChartConfiguration':
return (
fieldExists(
configuration.aggregateFieldMetadataId,
objectMetadataItem,
) &&
isRatioConfigValid(
configuration.ratioAggregateConfig,
objectMetadataItem,
)
);
case 'GaugeChartConfiguration':
return fieldExists(
configuration.aggregateFieldMetadataId,
objectMetadataItem,
);
case 'IframeConfiguration':
return isString(configuration.url) && configuration.url.trim().length > 0;
default:
return false;
}
};
@@ -0,0 +1,35 @@
import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType';
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration';
import { isDefined } from 'twenty-shared/utils';
import { type WidgetConfiguration } from '~/generated-metadata/graphql';
export const hasMinimalRequiredConfigForGraph = (
configuration: WidgetConfiguration | FieldsConfiguration | FieldConfiguration,
): boolean => {
if (
isWidgetConfigurationOfType(configuration, 'BarChartConfiguration') ||
isWidgetConfigurationOfType(configuration, 'LineChartConfiguration')
) {
return (
isDefined(configuration.aggregateFieldMetadataId) &&
isDefined(configuration.primaryAxisGroupByFieldMetadataId)
);
}
if (isWidgetConfigurationOfType(configuration, 'PieChartConfiguration')) {
return (
isDefined(configuration.aggregateFieldMetadataId) &&
isDefined(configuration.groupByFieldMetadataId)
);
}
if (
isWidgetConfigurationOfType(configuration, 'AggregateChartConfiguration') ||
isWidgetConfigurationOfType(configuration, 'GaugeChartConfiguration')
) {
return isDefined(configuration.aggregateFieldMetadataId);
}
return false;
};
@@ -32,6 +32,7 @@ export const TEST_BAR_CHART_CONFIGURATION: TypedBarChartConfiguration = {
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
displayDataLabel: true,
axisNameDisplay: AxisNameDisplay.NONE,
splitMultiValueFields: false,
};
export const TEST_BAR_CHART_CONFIGURATION_HORIZONTAL: TypedBarChartConfiguration =
@@ -50,6 +51,7 @@ export const TEST_LINE_CHART_CONFIGURATION: TypedLineChartConfiguration = {
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.DAY,
displayDataLabel: true,
axisNameDisplay: AxisNameDisplay.NONE,
splitMultiValueFields: false,
};
export const TEST_PIE_CHART_CONFIGURATION: TypedPieChartConfiguration = {
@@ -62,6 +64,7 @@ export const TEST_PIE_CHART_CONFIGURATION: TypedPieChartConfiguration = {
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
orderBy: GraphOrderBy.VALUE_DESC,
displayDataLabel: true,
splitMultiValueFields: false,
};
export const TEST_AGGREGATE_CHART_CONFIGURATION: TypedAggregateChartConfiguration =
@@ -459,10 +459,7 @@ export class PageLayoutUpdateService {
pageLayoutTabId: widgetInput.pageLayoutTabId,
objectMetadataId: widgetInput.objectMetadataId,
},
flatEntityMaps: {
flatPageLayoutTabMaps,
flatObjectMetadataMaps,
},
flatEntityMaps: { flatPageLayoutTabMaps, flatObjectMetadataMaps },
});
return {
@@ -505,12 +502,26 @@ export class PageLayoutUpdateService {
const updatedConfiguration = widgetInput.configuration ?? null;
const {
pageLayoutTabUniversalIdentifier,
objectMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'pageLayoutWidget',
foreignKeyValues: {
pageLayoutTabId: widgetInput.pageLayoutTabId,
objectMetadataId: widgetInput.objectMetadataId,
},
flatEntityMaps: { flatPageLayoutTabMaps, flatObjectMetadataMaps },
});
return {
...existingWidget,
pageLayoutTabId: widgetInput.pageLayoutTabId,
pageLayoutTabUniversalIdentifier,
title: widgetInput.title,
type: widgetInput.type,
objectMetadataId: widgetInput.objectMetadataId ?? null,
objectMetadataUniversalIdentifier,
gridPosition: widgetInput.gridPosition,
position: widgetInput.position ?? null,
configuration: updatedConfiguration,
@@ -536,12 +547,26 @@ export class PageLayoutUpdateService {
const restoredConfiguration = widgetInput.configuration ?? null;
const {
pageLayoutTabUniversalIdentifier,
objectMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'pageLayoutWidget',
foreignKeyValues: {
pageLayoutTabId: widgetInput.pageLayoutTabId,
objectMetadataId: widgetInput.objectMetadataId,
},
flatEntityMaps: { flatPageLayoutTabMaps, flatObjectMetadataMaps },
});
return {
...existingWidget,
pageLayoutTabId: widgetInput.pageLayoutTabId,
pageLayoutTabUniversalIdentifier,
title: widgetInput.title,
type: widgetInput.type,
objectMetadataId: widgetInput.objectMetadataId ?? null,
objectMetadataUniversalIdentifier,
gridPosition: widgetInput.gridPosition,
position: widgetInput.position ?? null,
configuration: restoredConfiguration,