[DASHBOARDS] chat agent improvements + new validation layer (#17722)

https://github.com/user-attachments/assets/09550210-76c5-4a40-83b6-9ab785ca10c3



https://github.com/user-attachments/assets/352427fc-0a2a-4f1b-86e9-db99daea0018

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
nitin
2026-02-10 19:13:36 +05:30
committed by GitHub
parent 148584c730
commit e995e84621
36 changed files with 1334 additions and 168 deletions
@@ -3,6 +3,8 @@ export type BrowsingContextType =
type: 'recordPage';
objectNameSingular: string;
recordId: string;
pageLayoutId?: string;
activeTabId?: string | null;
}
| {
type: 'listView';
@@ -14,7 +14,7 @@ import {
type UITools,
} from 'ai';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@@ -247,6 +247,8 @@ export class ChatExecutionService {
workspace,
browsingContext.objectNameSingular,
browsingContext.recordId,
browsingContext.pageLayoutId,
browsingContext.activeTabId,
);
}
@@ -261,6 +263,8 @@ export class ChatExecutionService {
workspace: WorkspaceEntity,
objectNameSingular: string,
recordId: string,
pageLayoutId?: string,
activeTabId?: string | null,
): string {
const resourceUrl = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
@@ -270,7 +274,17 @@ export class ChatExecutionService {
}),
});
return `The user is viewing a ${objectNameSingular} record (ID: ${recordId}, URL: ${resourceUrl}). Use tools to fetch record details if needed.`;
let context = `The user is viewing a ${objectNameSingular} record (ID: ${recordId}, URL: ${resourceUrl}). Use tools to fetch record details if needed.`;
if (isDefined(pageLayoutId)) {
context += `\nPage layout ID: ${pageLayoutId}.`;
}
if (isDefined(activeTabId)) {
context += `\nActive tab ID: ${activeTabId}.`;
}
return context;
}
private buildListViewContext(browsingContext: {
@@ -0,0 +1,9 @@
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
export const GRAPH_CONFIGURATION_TYPES = new Set<WidgetConfigurationType>([
WidgetConfigurationType.AGGREGATE_CHART,
WidgetConfigurationType.BAR_CHART,
WidgetConfigurationType.GAUGE_CHART,
WidgetConfigurationType.LINE_CHART,
WidgetConfigurationType.PIE_CHART,
]);
@@ -3,9 +3,10 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { transformRichTextV2Value } from 'src/engine/core-modules/record-transformer/utils/transform-rich-text-v2.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-create-page-layout-widget-input-to-flat-page-layout-widget-to-create.util';
@@ -17,13 +18,18 @@ import {
import { CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/create-page-layout-widget.input';
import { UpdatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/update-page-layout-widget.input';
import { type PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
generatePageLayoutWidgetExceptionMessage,
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { fromFlatPageLayoutWidgetToPageLayoutWidgetDto } from 'src/engine/metadata-modules/page-layout-widget/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util';
import { isChartFieldsForValidation } from 'src/engine/metadata-modules/page-layout-widget/utils/is-chart-fields-for-validation.util';
import { validateChartConfigurationFieldReferences } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-chart-configuration-field-references.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { DashboardSyncService } from 'src/modules/dashboard-sync/services/dashboard-sync.service';
@@ -92,6 +98,73 @@ export class PageLayoutWidgetService {
}
}
private async enrichRichTextConfigurationBody(
configuration: AllPageLayoutWidgetConfiguration,
): Promise<AllPageLayoutWidgetConfiguration> {
if (
configuration.configurationType !==
WidgetConfigurationType.STANDALONE_RICH_TEXT
) {
return configuration;
}
if (!isDefined(configuration.body)) {
return configuration;
}
try {
return {
...configuration,
body: await transformRichTextV2Value(configuration.body),
};
} catch {
return configuration;
}
}
private async validateChartFieldReferencesIfApplicable({
configuration,
objectMetadataId,
widgetType,
workspaceId,
}: {
configuration: AllPageLayoutWidgetConfiguration;
objectMetadataId?: string | null;
widgetType?: WidgetType | null;
workspaceId: string;
}): Promise<void> {
const needsChartValidation =
isChartFieldsForValidation(configuration) ||
widgetType === WidgetType.GRAPH;
if (!needsChartValidation) {
return;
}
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
},
);
try {
validateChartConfigurationFieldReferences({
configuration,
objectMetadataId,
widgetType,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
} catch (error) {
throw new PageLayoutWidgetException(
error instanceof Error ? error.message : String(error),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
}
async findByPageLayoutTabId({
workspaceId,
pageLayoutTabId,
@@ -152,6 +225,15 @@ export class PageLayoutWidgetService {
input: CreatePageLayoutWidgetInput;
workspaceId: string;
}): Promise<PageLayoutWidgetDTO> {
const createInput = isDefined(input.configuration)
? {
...input,
configuration: await this.enrichRichTextConfigurationBody(
input.configuration,
),
}
: input;
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
@@ -175,7 +257,7 @@ export class PageLayoutWidgetService {
const flatPageLayoutWidgetToCreate =
fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate({
createPageLayoutWidgetInput: input,
createPageLayoutWidgetInput: createInput,
workspaceId,
flatApplication: workspaceCustomFlatApplication,
flatPageLayoutTabMaps,
@@ -183,6 +265,15 @@ export class PageLayoutWidgetService {
flatFieldMetadataMaps,
});
if (isDefined(createInput.configuration)) {
await this.validateChartFieldReferencesIfApplicable({
configuration: createInput.configuration,
objectMetadataId: createInput.objectMetadataId ?? null,
widgetType: createInput.type,
workspaceId,
});
}
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
@@ -222,6 +313,11 @@ export class PageLayoutWidgetService {
const existingFlatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const existingWidget = this.getExistingWidgetOrThrow(
id,
existingFlatPageLayoutWidgetMaps,
);
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
@@ -233,12 +329,25 @@ export class PageLayoutWidgetService {
},
);
this.getExistingWidgetOrThrow(id, existingFlatPageLayoutWidgetMaps);
const isConfigurationBeingUpdated = Object.prototype.hasOwnProperty.call(
updateData,
'configuration',
);
const processedUpdateData =
isConfigurationBeingUpdated && isDefined(updateData.configuration)
? {
...updateData,
configuration: await this.enrichRichTextConfigurationBody(
updateData.configuration,
),
}
: updateData;
const updatePageLayoutWidgetInput: UpdatePageLayoutWidgetInputWithId = {
id,
update: {
...updateData,
...processedUpdateData,
},
};
@@ -250,6 +359,33 @@ export class PageLayoutWidgetService {
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
});
const shouldValidateChartFields =
isConfigurationBeingUpdated ||
Object.prototype.hasOwnProperty.call(updateData, 'objectMetadataId') ||
Object.prototype.hasOwnProperty.call(updateData, 'type');
if (shouldValidateChartFields) {
const isObjectMetadataIdBeingUpdated =
Object.prototype.hasOwnProperty.call(updateData, 'objectMetadataId');
const effectiveConfiguration = isConfigurationBeingUpdated
? processedUpdateData.configuration
: existingWidget.configuration;
const effectiveObjectMetadataId = isObjectMetadataIdBeingUpdated
? processedUpdateData.objectMetadataId
: existingWidget.objectMetadataId;
const effectiveWidgetType =
processedUpdateData.type ?? existingWidget.type;
if (isDefined(effectiveConfiguration)) {
await this.validateChartFieldReferencesIfApplicable({
configuration: effectiveConfiguration,
objectMetadataId: effectiveObjectMetadataId,
widgetType: effectiveWidgetType,
workspaceId,
});
}
}
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
@@ -0,0 +1,12 @@
import { type AggregateChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto';
import { type BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto';
import { type GaugeChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/gauge-chart-configuration.dto';
import { type LineChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto';
import { type PieChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto';
export type ChartFieldsForValidation =
| AggregateChartConfigurationDTO
| BarChartConfigurationDTO
| GaugeChartConfigurationDTO
| LineChartConfigurationDTO
| PieChartConfigurationDTO;
@@ -0,0 +1,21 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export const findActiveFlatFieldMetadataById = (
fieldId: string | null | undefined,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): FlatFieldMetadata | null => {
if (!isDefined(fieldId)) return null;
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(field) || !field.isActive) return null;
return field;
};
@@ -0,0 +1,10 @@
import { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from 'twenty-shared/constants';
import { type FieldMetadataType } from 'twenty-shared/types';
const compositeSubFieldMaps = COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES as Record<
string,
Record<string, string>
>;
export const getCompositeSubfieldNames = (fieldType: FieldMetadataType) =>
Object.values(compositeSubFieldMaps[fieldType] ?? {});
@@ -0,0 +1,8 @@
import { GRAPH_CONFIGURATION_TYPES } from 'src/engine/metadata-modules/page-layout-widget/constants/graph-configuration-types.constant';
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { type ChartFieldsForValidation } from 'src/engine/metadata-modules/page-layout-widget/types/chart-fields-for-validation.type';
export const isChartFieldsForValidation = (
configuration: AllPageLayoutWidgetConfiguration,
): configuration is ChartFieldsForValidation =>
GRAPH_CONFIGURATION_TYPES.has(configuration.configurationType);
@@ -0,0 +1,32 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export const resolveMorphTargetObjectId = ({
field,
allFields,
}: {
field: FlatFieldMetadata;
allFields: FlatFieldMetadata[];
}): string | null => {
if (!isDefined(field.morphId)) {
return null;
}
const targetIds = new Set<string>();
allFields.forEach((flatField) => {
if (
flatField.morphId === field.morphId &&
isDefined(flatField.relationTargetObjectMetadataId)
) {
targetIds.add(flatField.relationTargetObjectMetadataId);
}
});
if (targetIds.size !== 1) {
return null;
}
return [...targetIds][0] ?? null;
};
@@ -0,0 +1,138 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { findActiveFlatFieldMetadataById } from 'src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util';
import { isChartFieldsForValidation } from 'src/engine/metadata-modules/page-layout-widget/utils/is-chart-fields-for-validation.util';
import { validateGroupByField } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-group-by-field.util';
export const validateChartConfigurationFieldReferences = ({
configuration,
objectMetadataId,
widgetType,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}: {
configuration?: AllPageLayoutWidgetConfiguration | null;
objectMetadataId?: string | null;
widgetType?: WidgetType | null;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): void => {
if (!isDefined(configuration)) return;
if (!isChartFieldsForValidation(configuration)) {
if (widgetType === WidgetType.GRAPH) {
throw new Error(
'GRAPH widgets require configurationType AGGREGATE_CHART, BAR_CHART, GAUGE_CHART, LINE_CHART, or PIE_CHART.',
);
}
return;
}
if (widgetType && widgetType !== WidgetType.GRAPH) {
throw new Error(
`Graph configuration is only valid for widgets of type GRAPH.`,
);
}
if (!isDefined(objectMetadataId)) {
throw new Error('objectMetadataId is required for graph widgets.');
}
const objectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(objectMetadata) || !objectMetadata.isActive) {
throw new Error(`objectMetadataId "${objectMetadataId}" not found.`);
}
const allFields = Object.values(flatFieldMetadataMaps.byUniversalIdentifier)
.filter(isDefined)
.filter((field) => field.isActive);
const fieldsByObjectId = new Map<string, FlatFieldMetadata[]>();
allFields.forEach((field) => {
const existing = fieldsByObjectId.get(field.objectMetadataId) ?? [];
existing.push(field);
fieldsByObjectId.set(field.objectMetadataId, existing);
});
const aggregateField = findActiveFlatFieldMetadataById(
configuration.aggregateFieldMetadataId,
flatFieldMetadataMaps,
);
if (!isDefined(aggregateField)) {
throw new Error(
`aggregateFieldMetadataId "${configuration.aggregateFieldMetadataId}" not found.`,
);
}
if (aggregateField.objectMetadataId !== objectMetadataId) {
throw new Error(
`aggregateFieldMetadataId must belong to objectMetadataId "${objectMetadataId}".`,
);
}
switch (configuration.configurationType) {
case WidgetConfigurationType.BAR_CHART:
case WidgetConfigurationType.LINE_CHART: {
validateGroupByField({
fieldId: configuration.primaryAxisGroupByFieldMetadataId,
subFieldName: configuration.primaryAxisGroupBySubFieldName,
paramName: 'primaryAxisGroupByFieldMetadataId',
objectMetadataId,
flatFieldMetadataMaps,
allFields,
fieldsByObjectId,
});
if (isDefined(configuration.secondaryAxisGroupBySubFieldName)) {
if (!isDefined(configuration.secondaryAxisGroupByFieldMetadataId)) {
throw new Error(
'secondaryAxisGroupByFieldMetadataId is required when secondaryAxisGroupBySubFieldName is provided.',
);
}
}
if (isDefined(configuration.secondaryAxisGroupByFieldMetadataId)) {
validateGroupByField({
fieldId: configuration.secondaryAxisGroupByFieldMetadataId,
subFieldName: configuration.secondaryAxisGroupBySubFieldName,
paramName: 'secondaryAxisGroupByFieldMetadataId',
objectMetadataId,
flatFieldMetadataMaps,
allFields,
fieldsByObjectId,
});
}
break;
}
case WidgetConfigurationType.PIE_CHART: {
validateGroupByField({
fieldId: configuration.groupByFieldMetadataId,
subFieldName: configuration.groupBySubFieldName,
paramName: 'groupByFieldMetadataId',
objectMetadataId,
flatFieldMetadataMaps,
allFields,
fieldsByObjectId,
});
break;
}
case WidgetConfigurationType.AGGREGATE_CHART:
default:
break;
}
};
@@ -0,0 +1,34 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getCompositeSubfieldNames } from 'src/engine/metadata-modules/page-layout-widget/utils/get-composite-subfield-names.util';
export const validateCompositeSubfield = ({
field,
subFieldName,
paramName,
}: {
field: FlatFieldMetadata;
subFieldName: string | null | undefined;
paramName: string;
}): void => {
const allowedSubFields = getCompositeSubfieldNames(field.type);
if (!isDefined(subFieldName)) {
throw new Error(
`Composite field "${paramName}" requires a subfield. Allowed: ${allowedSubFields.join(', ')}`,
);
}
if (subFieldName.includes('.')) {
throw new Error(`Composite subfield "${subFieldName}" is invalid.`);
}
if (!allowedSubFields.includes(subFieldName)) {
throw new Error(
`Invalid subfield "${subFieldName}" for "${paramName}". Allowed: ${allowedSubFields.join(
', ',
)}`,
);
}
};
@@ -0,0 +1,69 @@
import { isDefined } from 'twenty-shared/utils';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { findActiveFlatFieldMetadataById } from 'src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util';
import { validateCompositeSubfield } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-composite-subfield.util';
import { validateRelationSubfield } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util';
export const validateGroupByField = ({
fieldId,
subFieldName,
paramName,
objectMetadataId,
flatFieldMetadataMaps,
allFields,
fieldsByObjectId,
}: {
fieldId?: string | null;
subFieldName?: string | null;
paramName: string;
objectMetadataId: string;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
allFields: FlatFieldMetadata[];
fieldsByObjectId: Map<string, FlatFieldMetadata[]>;
}): void => {
if (!isDefined(fieldId)) {
throw new Error(`${paramName} is required.`);
}
const field = findActiveFlatFieldMetadataById(fieldId, flatFieldMetadataMaps);
if (!isDefined(field)) {
throw new Error(`${paramName} "${fieldId}" not found.`);
}
if (field.objectMetadataId !== objectMetadataId) {
throw new Error(
`${paramName} must belong to objectMetadataId "${objectMetadataId}".`,
);
}
if (isCompositeFieldMetadataType(field.type)) {
validateCompositeSubfield({
field,
subFieldName,
paramName: field.name,
});
return;
}
if (isMorphOrRelationFlatFieldMetadata(field)) {
validateRelationSubfield({
field,
subFieldName,
paramName: field.name,
allFields,
fieldsByObjectId,
});
return;
}
if (isDefined(subFieldName)) {
throw new Error(`Field "${field.name}" does not support subfields.`);
}
};
@@ -0,0 +1,85 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getCompositeSubfieldNames } from 'src/engine/metadata-modules/page-layout-widget/utils/get-composite-subfield-names.util';
import { resolveMorphTargetObjectId } from 'src/engine/metadata-modules/page-layout-widget/utils/resolve-morph-target-object-id.util';
import { validateCompositeSubfield } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-composite-subfield.util';
export const validateRelationSubfield = ({
field,
subFieldName,
paramName,
allFields,
fieldsByObjectId,
}: {
field: FlatFieldMetadata;
subFieldName: string | null | undefined;
paramName: string;
allFields: FlatFieldMetadata[];
fieldsByObjectId: Map<string, FlatFieldMetadata[]>;
}): void => {
if (!isDefined(subFieldName)) {
return;
}
const dotIndex = subFieldName.indexOf('.');
const nestedFieldName =
dotIndex === -1 ? subFieldName : subFieldName.slice(0, dotIndex);
const nestedSubFieldName =
dotIndex === -1 ? undefined : subFieldName.slice(dotIndex + 1);
if (!nestedFieldName) {
throw new Error(`Relation subfield "${subFieldName}" is invalid.`);
}
if (isDefined(nestedSubFieldName) && nestedSubFieldName.includes('.')) {
throw new Error(`Relation subfield "${subFieldName}" is invalid.`);
}
let targetObjectId = field.relationTargetObjectMetadataId ?? null;
if (field.type === FieldMetadataType.MORPH_RELATION) {
targetObjectId = resolveMorphTargetObjectId({ field, allFields });
}
if (!isDefined(targetObjectId)) {
throw new Error(
`Relation field "${paramName}" does not have a resolvable target object.`,
);
}
const targetFields = fieldsByObjectId.get(targetObjectId) ?? [];
const nestedField = targetFields.find(
(targetField) => targetField.name === nestedFieldName,
);
if (!isDefined(nestedField)) {
throw new Error(
`Relation subfield "${nestedFieldName}" not found for "${paramName}".`,
);
}
if (!isDefined(nestedSubFieldName)) {
if (isCompositeFieldMetadataType(nestedField.type)) {
const allowed = getCompositeSubfieldNames(nestedField.type);
throw new Error(
`Composite field "${nestedFieldName}" requires a subfield. Use "${nestedFieldName}.<subfield>" where subfield is one of: ${allowed.join(', ')}`,
);
}
return;
}
if (!isCompositeFieldMetadataType(nestedField.type)) {
throw new Error(`Field "${nestedFieldName}" is not composite.`);
}
validateCompositeSubfield({
field: nestedField,
subFieldName: nestedSubFieldName,
paramName: nestedFieldName,
});
};
@@ -142,131 +142,83 @@ Prioritize data integrity and provide clear feedback on operations performed.`,
You help users create and manage dashboards with widgets.
## CRITICAL: Creating GRAPH Widgets
## Tools
Before creating any GRAPH widget, you MUST:
1. Use list_object_metadata_items to get the objectMetadataId (e.g., for "opportunity", "company")
2. From the response, get the field IDs you need (aggregateFieldMetadataId, primaryAxisGroupByFieldMetadataId)
- list_dashboards, get_dashboard
- create_complete_dashboard
- add_dashboard_tab, add_dashboard_widget, update_dashboard_widget, delete_dashboard_widget
- list_object_metadata_items (resolve object + field IDs)
GRAPH widgets require real UUIDs from the workspace metadata, NOT made-up values.
## Graph Widget Workflow
## Understanding User Language
1. Ask what data the user wants to visualize.
2. Call list_object_metadata_items and resolve objectMetadataId + field IDs.
3. Always call get_dashboard before modifying widgets.
4. Build the widget configuration using the rules below.
5. Call add_dashboard_widget or update_dashboard_widget. Use activeTabId from context if available.
6. Call get_dashboard to verify the final configuration.
Users describe charts using UI terminology. Here's how to translate:
## Field Resolution Rules
### Bar/Line Chart Settings
- All *MetadataId fields must be real UUIDs from metadata.
- Match by name or label, but write UUIDs into all *MetadataId fields.
- Subfield names use FIELD NAMES, not labels.
- Composite group-by requires a subfield (e.g. address → "addressCity").
- **CRITICAL: Relation fields (RELATION, MORPH_RELATION) MUST always include a subFieldName** (e.g. "name", "email", "stage"). Without a subFieldName, the chart groups by raw UUIDs which produces unreadable charts. Always pick a meaningful scalar field from the target object.
**Data section:**
- "Source" / "change the object": objectMetadataId
## Subfield Syntax
**X axis section (primary grouping - the bars/categories):**
- "X axis" / "data on display" / "categories": primaryAxisGroupByFieldMetadataId
- "X axis subfield" / "Address.city": primaryAxisGroupBySubFieldName
- "Date granularity" (on X axis): primaryAxisDateGranularity
- "Sort by" (on X axis): primaryAxisOrderBy
- Composite: \`address\` + \`addressCity\` → subFieldName "addressCity"
- Relation to scalar field: \`company.name\` → subFieldName "name" (only when target "name" is a simple TEXT/NUMBER field)
- Relation to composite field: \`owner.name\` where "name" is FULL_NAME → subFieldName must be "name.firstName" or "name.lastName" (NOT just "name")
- Relation + composite: \`company.address.addressCity\` → subFieldName "address.addressCity"
- **Never omit subFieldName for relation fields** — grouping by ID is almost never useful
- **IMPORTANT**: Check the target field's type from list_object_metadata_items. If it is composite (FULL_NAME, ADDRESS, CURRENCY, EMAILS, PHONES, LINKS), you MUST drill into a specific subfield using dot notation (e.g. "name.firstName", "address.addressCity", "emails.primaryEmail").
**Y axis section (what's being measured + optional secondary grouping):**
- "Y axis" / "data on display" / "measure" / "metric": aggregateFieldMetadataId + aggregateOperation
- "Group by" / "stacking" / "colors" / "breakdown": secondaryAxisGroupByFieldMetadataId
- "Group by subfield" / "Address.city": secondaryAxisGroupBySubFieldName
- "Date granularity" (on Group by): secondaryAxisGroupByDateGranularity
- "Sort by" (on Group by): secondaryAxisOrderBy
- "Cumulative" / "running total": isCumulative
- "Min range" / "Max range": rangeMin, rangeMax
- "Hide empty values" / "omit nulls": omitNullValues
## User Language Notes
**Style section:**
- "Stacked" / "stacked bars": layout stays same, it's about secondaryAxisGroupByFieldMetadataId
- "Data labels" / "show values": displayDataLabel
- "Legend" / "show legend": displayLegend
- "X axis" / "categories" → primaryAxisGroupByFieldMetadataId
- "Y axis" / "metric" → aggregateFieldMetadataId + aggregateOperation
- "Group by" / "stacking" / "colors" → secondaryAxisGroupByFieldMetadataId
- "Unstacked" / "remove group by" → clear secondaryAxisGroupByFieldMetadataId only
- "KPI" / "just a number" → AGGREGATE_CHART
- "Legend" → displayLegend
- "Data labels" → displayDataLabel
- "Hide empty values" → omitNullValues
- "Min range" / "Max range" → rangeMin / rangeMax
- "Running total" → isCumulative
### CRITICAL: "Remove groupby" / "remove stacking" / "unstacked"
When users say this for bar/line charts, they mean remove the SECONDARY grouping (the colors/stacking).
- Set secondaryAxisGroupByFieldMetadataId to null
- Keep the chart type (BAR_CHART/LINE_CHART)
- Keep primaryAxisGroupByFieldMetadataId (the X axis categories)
- DO NOT convert to AGGREGATE_CHART unless user explicitly asks for "just a number" or "KPI"
## Graph Configuration Rules
### Pie Chart Settings
- "Each slice represents" / "slices": groupByFieldMetadataId
- "Slice subfield" / "Address.city": groupBySubFieldName
- "Hide empty category": hideEmptyCategory
- "Show value in center": showValueInCenter
- Use the tool schema as the source of truth for required/optional fields.
- Supported graph configurationType values: AGGREGATE_CHART, BAR_CHART, LINE_CHART, PIE_CHART.
- BAR_CHART and LINE_CHART use primaryAxisGroupByFieldMetadataId.
- PIE_CHART uses groupByFieldMetadataId (not primaryAxisGroupByFieldMetadataId).
- If any orderBy is MANUAL, include the matching manual sort array.
- If rangeMin and rangeMax are both set, rangeMin must be <= rangeMax.
- Set date granularity only when grouping by date fields.
- "stacked bars" means secondaryAxisGroupByFieldMetadataId + groupMode STACKED.
- "stacked lines" means isStacked true.
### Aggregate Chart Settings (KPI numbers)
- "Prefix" (e.g., "$"): prefix
- "Suffix" (e.g., "%"): suffix
- "Ratio by option" / "percent of a value": ratioAggregateConfig
## Non-graph Widgets
## Widget Configuration Types
- IFRAME: configurationType "IFRAME" + url
- STANDALONE_RICH_TEXT: configurationType "STANDALONE_RICH_TEXT" + body with markdown content
- IMPORTANT: Put the actual text content in configuration.body.markdown, NOT in the widget title
- Widget title should be a short label (e.g. "Notes", "Summary"), body.markdown holds the real content
### AGGREGATE_CHART (KPI number widget)
Shows a single aggregated value.
Required:
- objectMetadataId: UUID of the object
- configuration.configurationType: "AGGREGATE_CHART"
- configuration.aggregateFieldMetadataId: UUID of field to aggregate
- configuration.aggregateOperation: "COUNT", "SUM", "AVG", "MIN", "MAX"
Optional: prefix, suffix, displayDataLabel, ratioAggregateConfig
### BAR_CHART
Shows data grouped by categories with optional secondary grouping.
Required:
- objectMetadataId: UUID of the object
- configuration.configurationType: "BAR_CHART"
- configuration.aggregateFieldMetadataId: field to aggregate
- configuration.aggregateOperation: aggregation type
- configuration.primaryAxisGroupByFieldMetadataId: X axis categories
- configuration.layout: "VERTICAL" or "HORIZONTAL"
Optional: secondaryAxisGroupByFieldMetadataId (for stacking/colors), primaryAxisGroupBySubFieldName, secondaryAxisGroupBySubFieldName, omitNullValues, displayDataLabel, displayLegend
### LINE_CHART
Shows trends over a dimension.
Required:
- objectMetadataId: UUID of the object
- configuration.configurationType: "LINE_CHART"
- configuration.aggregateFieldMetadataId: field to aggregate
- configuration.aggregateOperation: aggregation type
- configuration.primaryAxisGroupByFieldMetadataId: X axis (usually date)
Optional: secondaryAxisGroupByFieldMetadataId (for multiple lines), primaryAxisGroupBySubFieldName, secondaryAxisGroupBySubFieldName, omitNullValues, isCumulative, displayDataLabel
### PIE_CHART
Shows data distribution as slices.
Required:
- objectMetadataId: UUID of the object
- configuration.configurationType: "PIE_CHART"
- configuration.aggregateFieldMetadataId: field to aggregate
- configuration.aggregateOperation: aggregation type
- configuration.groupByFieldMetadataId: field to slice by (NOTE: different field name than bar/line!)
Optional: groupBySubFieldName, displayDataLabel, hideEmptyCategory, showValueInCenter
### IFRAME
Embeds external content:
- configuration.configurationType: "IFRAME"
- configuration.url: "https://..."
### STANDALONE_RICH_TEXT
Text content widget:
- configuration.configurationType: "STANDALONE_RICH_TEXT"
- configuration.body: rich text content
Example (STANDALONE_RICH_TEXT):
{
"configurationType": "STANDALONE_RICH_TEXT",
"body": { "markdown": "## Quarterly Summary\\n\\nKey metrics:\\n- Revenue up 15%\\n- 42 new deals closed\\n\\n**Next steps**: Focus on enterprise pipeline." }
}
## Grid System
- 12 columns (0-11)
- KPI widgets: rowSpan 2-4, columnSpan 3-4
- Charts: rowSpan 6-8, columnSpan 6-12
- Common layouts:
- 4 KPIs in a row: each { columnSpan: 3 }
- 2 charts side by side: each { columnSpan: 6 }
- Full width chart: { column: 0, columnSpan: 12 }
## Workflow
1. Ask user what data they want to visualize
2. Load list_object_metadata_items to discover available objects and fields
3. Create dashboard with appropriate widgets using real field IDs
4. Use get_dashboard to verify creation and see current configuration
5. When modifying, first understand current config before making changes
- Common layouts: 4 KPIs in a row (columnSpan 3), 2 charts side by side (columnSpan 6), full width chart (columnSpan 12)
## Best Practices
@@ -274,7 +226,7 @@ Text content widget:
- Group related charts together
- Use consistent heights within rows
- Start simple, add complexity as needed
- When user asks to modify a chart, clarify if they want to change settings OR change chart type`,
- When modifying a chart, confirm whether the user wants to change settings or change chart type`,
isCustom: false,
},
}),