diff --git a/packages/twenty-front/src/modules/ai/hooks/useBrowsingContext.ts b/packages/twenty-front/src/modules/ai/hooks/useBrowsingContext.ts index 3374be9568..4ef9052fc6 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useBrowsingContext.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useBrowsingContext.ts @@ -1,3 +1,7 @@ +import { t } from '@lingui/core/macro'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + import { type BrowsingContext } from '@/ai/types/BrowsingContext'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; @@ -7,9 +11,10 @@ import { contextStoreFiltersComponentState } from '@/context-store/states/contex import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector'; -import { t } from '@lingui/core/macro'; -import { useRecoilCallback } from 'recoil'; export const useGetBrowsingContext = () => { const getBrowsingContext = useRecoilCallback( @@ -61,11 +66,40 @@ export const useGetBrowsingContext = () => { return null; } - return { + const recordContext: BrowsingContext = { type: 'recordPage', objectNameSingular: objectMetadataItem.nameSingular, recordId: targetedRecordsRule.selectedRecordIds[0], }; + + const pageLayoutId = snapshot + .getLoadable( + recordStoreFamilySelector({ + recordId: targetedRecordsRule.selectedRecordIds[0], + fieldName: 'pageLayoutId', + }), + ) + .getValue(); + + if (isDefined(pageLayoutId)) { + const tabListInstanceId = + getTabListInstanceIdFromPageLayoutId(pageLayoutId); + const activeTabId = snapshot + .getLoadable( + activeTabIdComponentState.atomFamily({ + instanceId: tabListInstanceId, + }), + ) + .getValue(); + + return { + ...recordContext, + pageLayoutId, + activeTabId, + }; + } + + return recordContext; } if ( diff --git a/packages/twenty-front/src/modules/ai/types/BrowsingContext.ts b/packages/twenty-front/src/modules/ai/types/BrowsingContext.ts index 05a828d55d..edb69ff7bd 100644 --- a/packages/twenty-front/src/modules/ai/types/BrowsingContext.ts +++ b/packages/twenty-front/src/modules/ai/types/BrowsingContext.ts @@ -3,6 +3,8 @@ export type BrowsingContext = type: 'recordPage'; objectNameSingular: string; recordId: string; + pageLayoutId?: string; + activeTabId?: string | null; } | { type: 'listView'; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type.ts index f07ad6fe36..d43aa73166 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type.ts @@ -3,6 +3,8 @@ export type BrowsingContextType = type: 'recordPage'; objectNameSingular: string; recordId: string; + pageLayoutId?: string; + activeTabId?: string | null; } | { type: 'listView'; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 3d2f2db1ec..21386714ab 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -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: { diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/constants/graph-configuration-types.constant.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/constants/graph-configuration-types.constant.ts new file mode 100644 index 0000000000..f5ba40dcb5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/constants/graph-configuration-types.constant.ts @@ -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.AGGREGATE_CHART, + WidgetConfigurationType.BAR_CHART, + WidgetConfigurationType.GAUGE_CHART, + WidgetConfigurationType.LINE_CHART, + WidgetConfigurationType.PIE_CHART, +]); diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts index 502ca71d9c..3cbc3a7845 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts @@ -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 { + 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 { + 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 { + 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: { diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/types/chart-fields-for-validation.type.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/types/chart-fields-for-validation.type.ts new file mode 100644 index 0000000000..b1df69523a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/types/chart-fields-for-validation.type.ts @@ -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; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util.ts new file mode 100644 index 0000000000..7083179372 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util.ts @@ -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 | null => { + if (!isDefined(fieldId)) return null; + + const field = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: fieldId, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if (!isDefined(field) || !field.isActive) return null; + + return field; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/get-composite-subfield-names.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/get-composite-subfield-names.util.ts new file mode 100644 index 0000000000..1a83cb0745 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/get-composite-subfield-names.util.ts @@ -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 +>; + +export const getCompositeSubfieldNames = (fieldType: FieldMetadataType) => + Object.values(compositeSubFieldMaps[fieldType] ?? {}); diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/is-chart-fields-for-validation.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/is-chart-fields-for-validation.util.ts new file mode 100644 index 0000000000..541052dcad --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/is-chart-fields-for-validation.util.ts @@ -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); diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/resolve-morph-target-object-id.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/resolve-morph-target-object-id.util.ts new file mode 100644 index 0000000000..a34d52947c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/resolve-morph-target-object-id.util.ts @@ -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(); + + 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; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-chart-configuration-field-references.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-chart-configuration-field-references.util.ts new file mode 100644 index 0000000000..1bcdc45631 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-chart-configuration-field-references.util.ts @@ -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; + flatFieldMetadataMaps: FlatEntityMaps; +}): 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(); + + 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; + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-composite-subfield.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-composite-subfield.util.ts new file mode 100644 index 0000000000..019d70595d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-composite-subfield.util.ts @@ -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( + ', ', + )}`, + ); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-group-by-field.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-group-by-field.util.ts new file mode 100644 index 0000000000..39ebd4b6f4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-group-by-field.util.ts @@ -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; + allFields: FlatFieldMetadata[]; + fieldsByObjectId: Map; +}): 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.`); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util.ts new file mode 100644 index 0000000000..4f23a712ca --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util.ts @@ -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; +}): 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}." 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, + }); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts index 8787f67fe4..01ea3133b2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts @@ -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, }, }), diff --git a/packages/twenty-server/src/modules/dashboard/tools/__tests__/get-dashboard.tool.spec.ts b/packages/twenty-server/src/modules/dashboard/tools/__tests__/get-dashboard.tool.spec.ts new file mode 100644 index 0000000000..2fcc43d4f0 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/__tests__/get-dashboard.tool.spec.ts @@ -0,0 +1,162 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; +import { createGetDashboardTool } from 'src/modules/dashboard/tools/get-dashboard.tool'; +import { type DashboardToolDependencies } from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type'; + +const WORKSPACE_ID = '20202020-aaaa-4d02-bf25-6aeccf7ea419'; + +const AGG_FIELD_ID = '20202020-bbbb-4d02-bf25-6aeccf7ea419'; +const OWNER_FIELD_ID = '20202020-cccc-4d02-bf25-6aeccf7ea419'; +const PERSON_OBJECT_ID = '20202020-dddd-4d02-bf25-6aeccf7ea419'; +const PERSON_ADDRESS_FIELD_ID = '20202020-eeee-4d02-bf25-6aeccf7ea419'; + +const flatFieldMetadataMaps = { + byUniversalIdentifier: { + 'field-amount': { + id: AGG_FIELD_ID, + name: 'amount', + label: 'Amount', + objectMetadataId: 'company', + isActive: true, + type: FieldMetadataType.NUMBER, + }, + 'field-owner': { + id: OWNER_FIELD_ID, + name: 'owner', + label: 'Owner', + objectMetadataId: 'company', + isActive: true, + type: FieldMetadataType.RELATION, + relationTargetObjectMetadataId: PERSON_OBJECT_ID, + }, + 'field-person-address': { + id: PERSON_ADDRESS_FIELD_ID, + name: 'address', + label: 'Address', + objectMetadataId: PERSON_OBJECT_ID, + isActive: true, + type: FieldMetadataType.ADDRESS, + }, + }, + universalIdentifierById: { + [AGG_FIELD_ID]: 'field-amount', + [OWNER_FIELD_ID]: 'field-owner', + [PERSON_ADDRESS_FIELD_ID]: 'field-person-address', + }, + universalIdentifiersByApplicationId: {}, +}; + +describe('get_dashboard tool', () => { + it('adds resolved fields to configuration', async () => { + const dashboard = { + id: 'dashboard-1', + title: 'Test Dashboard', + pageLayoutId: 'layout-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const pageLayout = { + id: 'layout-1', + name: 'Layout', + tabs: [ + { + id: 'tab-1', + title: 'Tab', + position: 0, + widgets: [ + { + id: 'widget-1', + title: 'Widget', + type: 'GRAPH', + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + objectMetadataId: 'company', + configuration: { + configurationType: WidgetConfigurationType.BAR_CHART, + aggregateFieldMetadataId: AGG_FIELD_ID, + aggregateOperation: 'COUNT', + primaryAxisGroupByFieldMetadataId: OWNER_FIELD_ID, + primaryAxisGroupBySubFieldName: 'address.addressCity', + layout: 'VERTICAL', + }, + }, + { + id: 'widget-2', + title: 'Widget 2', + type: 'GRAPH', + gridPosition: { row: 4, column: 0, rowSpan: 4, columnSpan: 4 }, + objectMetadataId: 'company', + configuration: { + configurationType: WidgetConfigurationType.BAR_CHART, + aggregateFieldMetadataId: 'missing-field', + aggregateOperation: 'COUNT', + primaryAxisGroupByFieldMetadataId: 'missing-groupby', + layout: 'VERTICAL', + }, + }, + ], + }, + ], + }; + + const deps = { + pageLayoutService: { + findByIdOrThrow: jest.fn().mockResolvedValue(pageLayout), + }, + globalWorkspaceOrmManager: { + executeInWorkspaceContext: jest + .fn() + .mockImplementation(async (fn) => fn()), + getRepository: jest.fn().mockResolvedValue({ + findOne: jest.fn().mockResolvedValue(dashboard), + }), + }, + flatEntityMapsCacheService: { + getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({ + flatFieldMetadataMaps, + }), + }, + }; + + const tool = createGetDashboardTool( + deps as unknown as Pick< + DashboardToolDependencies, + | 'pageLayoutService' + | 'globalWorkspaceOrmManager' + | 'flatEntityMapsCacheService' + >, + { + workspaceId: WORKSPACE_ID, + }, + ); + + const result = await tool.execute({ dashboardId: dashboard.id }); + + expect(result.success).toBe(true); + const widgets = result.result?.layout?.tabs?.[0]?.widgets ?? []; + + const configuration = widgets[0]?.configuration as + | { + _resolved?: { + aggregateField?: { fieldLabel?: string }; + primaryAxisGroupBy?: { fullPath?: string; subFieldLabel?: string }; + }; + } + | undefined; + + expect(configuration?._resolved?.aggregateField?.fieldLabel).toBe('Amount'); + expect(configuration?._resolved?.primaryAxisGroupBy?.fullPath).toBe( + 'owner.address.addressCity', + ); + expect(configuration?._resolved?.primaryAxisGroupBy?.subFieldLabel).toBe( + 'Address City', + ); + + const missingResolved = ( + widgets[1]?.configuration as { _resolved?: unknown } | undefined + )?._resolved; + + expect(missingResolved).toBeUndefined(); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-tab.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-tab.tool.ts new file mode 100644 index 0000000000..5a9823b1cc --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-tab.tool.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; + +import { + type DashboardToolContext, + type DashboardToolDependencies, +} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type'; + +const addDashboardTabSchema = z.object({ + pageLayoutId: z + .string() + .uuid() + .describe( + 'The page layout UUID of the dashboard (from get_dashboard result)', + ), + title: z.string().describe('Title for the new tab'), + position: z + .number() + .int() + .min(0) + .optional() + .describe( + 'Tab position (0-based). Defaults to after the last existing tab.', + ), +}); + +export const createAddDashboardTabTool = ( + deps: Pick< + DashboardToolDependencies, + 'pageLayoutTabService' | 'pageLayoutService' + >, + context: DashboardToolContext, +) => ({ + name: 'add_dashboard_tab' as const, + description: `Add a new tab to an existing dashboard. + +Use get_dashboard first to get the pageLayoutId and see existing tabs. +After creating a tab, use add_dashboard_widget with the returned tab ID to add widgets.`, + inputSchema: addDashboardTabSchema, + execute: async (parameters: { + pageLayoutId: string; + title: string; + position?: number; + }) => { + try { + const pageLayout = await deps.pageLayoutService.findByIdOrThrow({ + id: parameters.pageLayoutId, + workspaceId: context.workspaceId, + }); + + const existingTabCount = pageLayout.tabs?.length ?? 0; + const position = parameters.position ?? existingTabCount; + + const tab = await deps.pageLayoutTabService.create({ + createPageLayoutTabInput: { + title: parameters.title, + pageLayoutId: parameters.pageLayoutId, + position, + }, + workspaceId: context.workspaceId, + }); + + return { + success: true, + message: `Tab "${parameters.title}" added to dashboard`, + result: { + pageLayoutTabId: tab.id, + title: tab.title, + position: tab.position, + pageLayoutId: parameters.pageLayoutId, + }, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + return { + success: false, + message: `Failed to add tab: ${errorMessage}`, + error: errorMessage, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts index 2a301a2316..315ce06b37 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts @@ -66,7 +66,9 @@ WIDGET TYPES: 2. GRAPH with configurationType "BAR_CHART": - Additional required: configuration.primaryAxisGroupByFieldMetadataId, configuration.layout ("VERTICAL" or "HORIZONTAL") - - Example: { type: "GRAPH", objectMetadataId: "", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "", aggregateOperation: "COUNT", primaryAxisGroupByFieldMetadataId: "", layout: "VERTICAL" } } + - IMPORTANT: When grouping by a RELATION field (e.g. owner, company), you MUST provide primaryAxisGroupBySubFieldName (e.g. "name", "email") — otherwise it groups by raw UUID which is useless. Composite fields (e.g. address) also require a subfield (e.g. "addressCity"). + - Example (simple field): { type: "GRAPH", objectMetadataId: "", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "", aggregateOperation: "COUNT", primaryAxisGroupByFieldMetadataId: "", layout: "VERTICAL" } } + - Example (relation field): { type: "GRAPH", objectMetadataId: "", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "", aggregateOperation: "SUM", primaryAxisGroupByFieldMetadataId: "", primaryAxisGroupBySubFieldName: "name", layout: "VERTICAL" } } 3. GRAPH with configurationType "LINE_CHART": - Additional required: configuration.primaryAxisGroupByFieldMetadataId @@ -101,7 +103,6 @@ AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY` try { const tabTitle = parameters.tabTitle ?? 'Main'; const widgets = parameters.widgets ?? []; - const pageLayout = await deps.pageLayoutService.create({ createPageLayoutInput: { name: parameters.title, diff --git a/packages/twenty-server/src/modules/dashboard/tools/dashboard-tools.module.ts b/packages/twenty-server/src/modules/dashboard/tools/dashboard-tools.module.ts index 1b137cf090..133f893673 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/dashboard-tools.module.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/dashboard-tools.module.ts @@ -3,6 +3,7 @@ import { Global, Module } from '@nestjs/common'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module'; import { DASHBOARD_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { PageLayoutTabModule } from 'src/engine/metadata-modules/page-layout-tab/page-layout-tab.module'; import { PageLayoutWidgetModule } from 'src/engine/metadata-modules/page-layout-widget/page-layout-widget.module'; import { PageLayoutModule } from 'src/engine/metadata-modules/page-layout/page-layout.module'; @@ -19,6 +20,7 @@ import { DashboardToolWorkspaceService } from './services/dashboard-tool.workspa RecordPositionModule, TwentyORMModule, ApplicationModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, ], providers: [ DashboardToolWorkspaceService, diff --git a/packages/twenty-server/src/modules/dashboard/tools/get-dashboard.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/get-dashboard.tool.ts index 6abbafae66..cd451bb7b4 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/get-dashboard.tool.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/get-dashboard.tool.ts @@ -1,11 +1,17 @@ import { isDefined } from 'twenty-shared/utils'; import { z } from 'zod'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-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 { 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 { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; import { type DashboardToolContext, type DashboardToolDependencies, } from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type'; +import { buildResolvedGroupBy } from 'src/modules/dashboard/tools/utils/build-resolved-group-by.util'; const getDashboardSchema = z.object({ dashboardId: z.string().uuid().describe('The UUID of the dashboard to fetch'), @@ -14,7 +20,9 @@ const getDashboardSchema = z.object({ export const createGetDashboardTool = ( deps: Pick< DashboardToolDependencies, - 'pageLayoutService' | 'globalWorkspaceOrmManager' + | 'pageLayoutService' + | 'globalWorkspaceOrmManager' + | 'flatEntityMapsCacheService' >, context: DashboardToolContext, ) => ({ @@ -24,6 +32,43 @@ export const createGetDashboardTool = ( execute: async (parameters: { dashboardId: string }) => { try { const authContext = buildSystemAuthContext(context.workspaceId); + const { flatFieldMetadataMaps } = + await deps.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId: context.workspaceId, + flatMapsKeys: ['flatFieldMetadataMaps'], + }, + ); + + const allFields = Object.values( + flatFieldMetadataMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((field) => field.isActive); + + const fieldsByObjectId = new Map(); + + allFields.forEach((field) => { + const existing = fieldsByObjectId.get(field.objectMetadataId) ?? []; + + existing.push(field); + fieldsByObjectId.set(field.objectMetadataId, existing); + }); + + const buildResolvedGroupByForConfiguration = ({ + fieldId, + subFieldName, + }: { + fieldId?: string | null; + subFieldName?: string | null; + }) => + buildResolvedGroupBy({ + fieldId, + subFieldName, + flatFieldMetadataMaps, + fieldsByObjectId, + allFields, + }); const dashboard = await deps.globalWorkspaceOrmManager.executeInWorkspaceContext( @@ -66,14 +111,90 @@ export const createGetDashboardTool = ( title: tab.title, position: tab.position, widgets: - tab.widgets?.map((w) => ({ - id: w.id, - title: w.title, - type: w.type, - gridPosition: w.gridPosition, - objectMetadataId: w.objectMetadataId, - configuration: w.configuration, - })) ?? [], + tab.widgets?.map((w) => { + if ( + w.type !== WidgetType.GRAPH || + !isChartFieldsForValidation(w.configuration) + ) { + return { + id: w.id, + title: w.title, + type: w.type, + gridPosition: w.gridPosition, + objectMetadataId: w.objectMetadataId, + configuration: w.configuration, + }; + } + + const configuration = w.configuration; + const resolved: Record = {}; + + const aggregateField = findActiveFlatFieldMetadataById( + configuration.aggregateFieldMetadataId, + flatFieldMetadataMaps, + ); + + if (isDefined(aggregateField)) { + resolved.aggregateField = { + fieldName: aggregateField.name, + fieldLabel: aggregateField.label ?? aggregateField.name, + }; + } + + switch (configuration.configurationType) { + case WidgetConfigurationType.BAR_CHART: + case WidgetConfigurationType.LINE_CHART: { + const primaryResolved = buildResolvedGroupByForConfiguration({ + fieldId: configuration.primaryAxisGroupByFieldMetadataId, + subFieldName: configuration.primaryAxisGroupBySubFieldName, + }); + const secondaryResolved = + buildResolvedGroupByForConfiguration({ + fieldId: + configuration.secondaryAxisGroupByFieldMetadataId, + subFieldName: + configuration.secondaryAxisGroupBySubFieldName, + }); + + if (isDefined(primaryResolved)) { + resolved.primaryAxisGroupBy = primaryResolved; + } + if (isDefined(secondaryResolved)) { + resolved.secondaryAxisGroupBy = secondaryResolved; + } + break; + } + case WidgetConfigurationType.PIE_CHART: { + const groupByResolved = buildResolvedGroupByForConfiguration({ + fieldId: configuration.groupByFieldMetadataId, + subFieldName: configuration.groupBySubFieldName, + }); + + if (isDefined(groupByResolved)) { + resolved.groupBy = groupByResolved; + } + break; + } + case WidgetConfigurationType.AGGREGATE_CHART: + default: + break; + } + + const enrichedConfiguration = { + ...configuration, + _resolved: + Object.keys(resolved).length > 0 ? resolved : undefined, + }; + + return { + id: w.id, + title: w.title, + type: w.type, + gridPosition: w.gridPosition, + objectMetadataId: w.objectMetadataId, + configuration: enrichedConfiguration, + }; + }) ?? [], })) ?? []; return { @@ -91,10 +212,13 @@ export const createGetDashboardTool = ( }, }; } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { success: false, - message: `Failed to get dashboard: ${error.message}`, - error: error.message, + message: `Failed to get dashboard: ${errorMessage}`, + error: errorMessage, }; } }, diff --git a/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts b/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts index 068f76ed2a..4717bd8f0f 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts @@ -224,9 +224,19 @@ const barChartConfigSchemaCore = z.object({ primaryAxisGroupByFieldMetadataId: z .uuid() .describe('Field UUID to group by on primary axis'), - primaryAxisGroupBySubFieldName: z.string().optional(), + primaryAxisGroupBySubFieldName: z + .string() + .optional() + .describe( + 'REQUIRED for relation fields (e.g. "name", "address.addressCity") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.', + ), secondaryAxisGroupByFieldMetadataId: z.uuid().optional(), - secondaryAxisGroupBySubFieldName: z.string().optional(), + secondaryAxisGroupBySubFieldName: z + .string() + .optional() + .describe( + 'REQUIRED for relation fields (e.g. "name", "stage") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.', + ), primaryAxisOrderBy: z.enum(GRAPH_ORDER_BY_OPTIONS).optional(), primaryAxisManualSortOrder: z.array(z.string()).optional(), secondaryAxisOrderBy: z.enum(GRAPH_ORDER_BY_OPTIONS).optional(), @@ -278,9 +288,19 @@ const lineChartConfigSchemaCore = z.object({ aggregateFieldMetadataId: z.uuid(), aggregateOperation: z.enum(AGGREGATE_OPERATION_OPTIONS), primaryAxisGroupByFieldMetadataId: z.uuid(), - primaryAxisGroupBySubFieldName: z.string().optional(), + primaryAxisGroupBySubFieldName: z + .string() + .optional() + .describe( + 'REQUIRED for relation fields (e.g. "name", "address.addressCity") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.', + ), secondaryAxisGroupByFieldMetadataId: z.uuid().optional(), - secondaryAxisGroupBySubFieldName: z.string().optional(), + secondaryAxisGroupBySubFieldName: z + .string() + .optional() + .describe( + 'REQUIRED for relation fields (e.g. "name", "stage") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.', + ), primaryAxisOrderBy: z.enum(GRAPH_ORDER_BY_OPTIONS).optional(), primaryAxisManualSortOrder: z.array(z.string()).optional(), secondaryAxisOrderBy: z.enum(GRAPH_ORDER_BY_OPTIONS).optional(), @@ -326,7 +346,12 @@ const pieChartConfigSchemaCore = z.object({ aggregateFieldMetadataId: z.uuid(), aggregateOperation: z.enum(AGGREGATE_OPERATION_OPTIONS), groupByFieldMetadataId: z.uuid().describe('Field UUID to slice by'), - groupBySubFieldName: z.string().optional(), + groupBySubFieldName: z + .string() + .optional() + .describe( + 'REQUIRED for relation fields (e.g. "name", "stage") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.', + ), orderBy: z.enum(GRAPH_ORDER_BY_OPTIONS).optional(), manualSortOrder: z.array(z.string()).optional(), dateGranularity: z @@ -364,10 +389,24 @@ const richTextConfigSchema = z.object({ configurationType: z.literal(WidgetConfigurationType.STANDALONE_RICH_TEXT), body: z .object({ - blocknote: z.string().nullable().optional(), - markdown: z.string().nullable().optional(), + blocknote: z + .string() + .nullable() + .optional() + .describe( + 'BlockNote JSON string (advanced). Stringified array of BlockNote blocks.', + ), + markdown: z + .string() + .nullable() + .optional() + .describe( + 'Markdown content string (preferred for AI). Supports headings, bold, lists, links, etc.', + ), }) - .describe('Rich text content (RichTextV2Body)'), + .describe( + 'Rich text content. Use { "markdown": "your content here" } for text. Supports full markdown syntax.', + ), }); export const graphConfigurationSchema = z.discriminatedUnion( diff --git a/packages/twenty-server/src/modules/dashboard/tools/services/dashboard-tool.workspace-service.ts b/packages/twenty-server/src/modules/dashboard/tools/services/dashboard-tool.workspace-service.ts index a1009e123f..aa2f0647b8 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/services/dashboard-tool.workspace-service.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/services/dashboard-tool.workspace-service.ts @@ -4,11 +4,13 @@ import { type ToolSet } from 'ai'; import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service'; import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service'; import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; +import { createAddDashboardTabTool } from 'src/modules/dashboard/tools/add-dashboard-tab.tool'; import { createAddDashboardWidgetTool } from 'src/modules/dashboard/tools/add-dashboard-widget.tool'; import { createCreateCompleteDashboardTool } from 'src/modules/dashboard/tools/create-complete-dashboard.tool'; import { createDeleteDashboardWidgetTool } from 'src/modules/dashboard/tools/delete-dashboard-widget.tool'; @@ -28,6 +30,7 @@ export class DashboardToolWorkspaceService { globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, recordPositionService: RecordPositionService, applicationService: ApplicationService, + flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, ) { this.deps = { pageLayoutService, @@ -36,6 +39,7 @@ export class DashboardToolWorkspaceService { globalWorkspaceOrmManager, recordPositionService, applicationService, + flatEntityMapsCacheService, }; } @@ -51,6 +55,7 @@ export class DashboardToolWorkspaceService { ); const listDashboards = createListDashboardsTool(this.deps, context); const getDashboard = createGetDashboardTool(this.deps, context); + const addDashboardTab = createAddDashboardTabTool(this.deps, context); const addDashboardWidget = createAddDashboardWidgetTool(this.deps, context); const updateDashboardWidget = createUpdateDashboardWidgetTool( this.deps, @@ -65,6 +70,7 @@ export class DashboardToolWorkspaceService { [createCompleteDashboard.name]: createCompleteDashboard, [listDashboards.name]: listDashboards, [getDashboard.name]: getDashboard, + [addDashboardTab.name]: addDashboardTab, [addDashboardWidget.name]: addDashboardWidget, [updateDashboardWidget.name]: updateDashboardWidget, [deleteDashboardWidget.name]: deleteDashboardWidget, diff --git a/packages/twenty-server/src/modules/dashboard/tools/types/dashboard-tool-dependencies.type.ts b/packages/twenty-server/src/modules/dashboard/tools/types/dashboard-tool-dependencies.type.ts index a2369d7217..36a598199b 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/types/dashboard-tool-dependencies.type.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/types/dashboard-tool-dependencies.type.ts @@ -3,6 +3,7 @@ import type { RecordPositionService } from 'src/engine/core-modules/record-posit import type { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service'; import type { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service'; import type { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service'; +import type { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import type { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import type { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; @@ -10,6 +11,7 @@ export type DashboardToolDependencies = { pageLayoutService: PageLayoutService; pageLayoutTabService: PageLayoutTabService; pageLayoutWidgetService: PageLayoutWidgetService; + flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService; globalWorkspaceOrmManager: GlobalWorkspaceOrmManager; recordPositionService: RecordPositionService; applicationService: ApplicationService; diff --git a/packages/twenty-server/src/modules/dashboard/tools/update-dashboard-widget.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/update-dashboard-widget.tool.ts index 5a6a615b1f..bfef2bcb91 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/update-dashboard-widget.tool.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/update-dashboard-widget.tool.ts @@ -2,6 +2,7 @@ import { isDefined, isEmptyObject } from 'twenty-shared/utils'; import { z } from 'zod'; import { type 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 { gridPositionSchema, widgetConfigurationSchemaWithoutDefaults, @@ -49,7 +50,7 @@ Only provide fields you want to change - others remain unchanged.`, columnSpan: number; }; objectMetadataId?: string; - configuration?: Record; + configuration?: AllPageLayoutWidgetConfiguration; }) => { try { const { widgetId, ...updates } = parameters; diff --git a/packages/twenty-server/src/modules/dashboard/tools/utils/__tests__/humanize-sub-field-label.util.spec.ts b/packages/twenty-server/src/modules/dashboard/tools/utils/__tests__/humanize-sub-field-label.util.spec.ts new file mode 100644 index 0000000000..b584c67852 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/utils/__tests__/humanize-sub-field-label.util.spec.ts @@ -0,0 +1,50 @@ +import { humanizeSubFieldLabel } from 'src/modules/dashboard/tools/utils/humanize-sub-field-label.util'; + +describe('humanizeSubFieldLabel', () => { + it('returns empty string for empty input', () => { + expect(humanizeSubFieldLabel('')).toBe(''); + }); + + it('handles camelCase field names', () => { + expect(humanizeSubFieldLabel('addressCity')).toBe('Address City'); + expect(humanizeSubFieldLabel('firstName')).toBe('First Name'); + expect(humanizeSubFieldLabel('primaryEmailAddress')).toBe( + 'Primary Email Address', + ); + }); + + it('handles single word', () => { + expect(humanizeSubFieldLabel('id')).toBe('Id'); + expect(humanizeSubFieldLabel('name')).toBe('Name'); + }); + + it('handles snake_case field names', () => { + expect(humanizeSubFieldLabel('address_city')).toBe('Address City'); + expect(humanizeSubFieldLabel('first_name')).toBe('First Name'); + }); + + it('handles kebab-case field names', () => { + expect(humanizeSubFieldLabel('address-city')).toBe('Address City'); + }); + + it('handles mixed separators', () => { + expect(humanizeSubFieldLabel('address_cityName')).toBe('Address City Name'); + }); + + it('handles consecutive separators', () => { + expect(humanizeSubFieldLabel('foo__bar')).toBe('Foo Bar'); + expect(humanizeSubFieldLabel('foo--bar')).toBe('Foo Bar'); + }); + + it('handles uppercase input', () => { + expect(humanizeSubFieldLabel('ADDRESS')).toBe('Address'); + }); + + it('handles whitespace', () => { + expect(humanizeSubFieldLabel(' firstName ')).toBe('First Name'); + }); + + it('handles numbers in field names', () => { + expect(humanizeSubFieldLabel('address2City')).toBe('Address2 City'); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/tools/utils/build-resolved-group-by.util.ts b/packages/twenty-server/src/modules/dashboard/tools/utils/build-resolved-group-by.util.ts new file mode 100644 index 0000000000..ddaed85ca3 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/utils/build-resolved-group-by.util.ts @@ -0,0 +1,91 @@ +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 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 { resolveMorphTargetObjectId } from 'src/engine/metadata-modules/page-layout-widget/utils/resolve-morph-target-object-id.util'; +import { humanizeSubFieldLabel } from 'src/modules/dashboard/tools/utils/humanize-sub-field-label.util'; + +type ResolvedGroupBy = { + fieldName: string; + fieldLabel: string; + fullPath: string; + subFieldName?: string; + subFieldLabel?: string; +}; + +export const buildResolvedGroupBy = ({ + fieldId, + subFieldName, + flatFieldMetadataMaps, + fieldsByObjectId, + allFields, +}: { + fieldId?: string | null; + subFieldName?: string | null; + flatFieldMetadataMaps: FlatEntityMaps; + fieldsByObjectId: Map; + allFields: FlatFieldMetadata[]; +}) => { + const field = findActiveFlatFieldMetadataById(fieldId, flatFieldMetadataMaps); + + if (!isDefined(field)) return null; + + const resolved: ResolvedGroupBy = { + fieldName: field.name, + fieldLabel: field.label ?? field.name, + fullPath: field.name, + }; + + if (isMorphOrRelationFlatFieldMetadata(field)) { + if (isDefined(subFieldName)) { + resolved.subFieldName = subFieldName; + resolved.fullPath = `${field.name}.${subFieldName}`; + + const dotIndex = subFieldName.indexOf('.'); + const nestedFieldName = + dotIndex === -1 ? subFieldName : subFieldName.slice(0, dotIndex); + const nestedSubFieldName = + dotIndex === -1 ? undefined : subFieldName.slice(dotIndex + 1); + + const targetObjectId = + field.type === FieldMetadataType.MORPH_RELATION + ? resolveMorphTargetObjectId({ field, allFields }) + : field.relationTargetObjectMetadataId; + + const targetFields = isDefined(targetObjectId) + ? (fieldsByObjectId.get(targetObjectId) ?? []) + : []; + const nestedField = targetFields.find( + (targetField) => targetField.name === nestedFieldName, + ); + + if (isDefined(nestedField)) { + if (!isDefined(nestedSubFieldName)) { + resolved.subFieldLabel = nestedField.label ?? nestedField.name; + } else if (isCompositeFieldMetadataType(nestedField.type)) { + resolved.subFieldLabel = humanizeSubFieldLabel(nestedSubFieldName); + } + } + } else { + resolved.fullPath = `${field.name}Id`; + } + + return resolved; + } + + if (isCompositeFieldMetadataType(field.type)) { + if (isDefined(subFieldName)) { + resolved.subFieldName = subFieldName; + resolved.subFieldLabel = humanizeSubFieldLabel(subFieldName); + resolved.fullPath = `${field.name}.${subFieldName}`; + } + + return resolved; + } + + return resolved; +}; diff --git a/packages/twenty-server/src/modules/dashboard/tools/utils/humanize-sub-field-label.util.ts b/packages/twenty-server/src/modules/dashboard/tools/utils/humanize-sub-field-label.util.ts new file mode 100644 index 0000000000..6819b6bc0b --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/utils/humanize-sub-field-label.util.ts @@ -0,0 +1,18 @@ +export const humanizeSubFieldLabel = (value: string) => { + if (!value) return ''; + + const withSpaces = value + .replace(/[_-]+/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/\s+/g, ' ') + .trim(); + + return withSpaces + .split(' ') + .map((part) => + part.length > 0 + ? part[0].toUpperCase() + part.slice(1).toLowerCase() + : '', + ) + .join(' '); +}; diff --git a/packages/twenty-server/test/integration/constants/widget-configuration-test-data.constants.ts b/packages/twenty-server/test/integration/constants/widget-configuration-test-data.constants.ts index 77cc8ae093..ea23e77598 100644 --- a/packages/twenty-server/test/integration/constants/widget-configuration-test-data.constants.ts +++ b/packages/twenty-server/test/integration/constants/widget-configuration-test-data.constants.ts @@ -42,7 +42,9 @@ export const TEST_STANDALONE_RICH_TEXT_CONFIG_MINIMAL: StandaloneRichTextConfigu { configurationType: WidgetConfigurationType.STANDALONE_RICH_TEXT, body: { - markdown: 'Simple text', + blocknote: + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Simple text"}]}]}', + markdown: null, }, }; @@ -54,6 +56,15 @@ export const INVALID_STANDALONE_RICH_TEXT_CONFIG_BODY_WRONG_TYPE = { body: 'not an object', }; +export const INVALID_STANDALONE_RICH_TEXT_CONFIG_INVALID_SUBFIELDS = { + configurationType: WidgetConfigurationType.STANDALONE_RICH_TEXT, + body: { + blocknote: 'valid', + markdown: 'valid', + invalidField: 'should not be here', + }, +}; + export const INVALID_IFRAME_CONFIG_BAD_URL = { url: 'not-a-valid-url', }; diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/__snapshots__/successful-page-layout-widget-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/__snapshots__/successful-page-layout-widget-creation.integration-spec.ts.snap index b6a7a70811..52f9e3b99e 100644 --- a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/__snapshots__/successful-page-layout-widget-creation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/__snapshots__/successful-page-layout-widget-creation.integration-spec.ts.snap @@ -25,7 +25,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Number Chart Widget", "type": "GRAPH", @@ -58,7 +58,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Number Chart Widget Minimal", "type": "GRAPH", @@ -88,7 +88,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Gauge Chart Widget", "type": "GRAPH", @@ -118,7 +118,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Gauge Chart Widget Minimal", "type": "GRAPH", @@ -166,7 +166,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Horizontal Bar Chart Widget", "type": "GRAPH", @@ -214,7 +214,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Horizontal Bar Chart Widget Minimal", "type": "GRAPH", @@ -293,7 +293,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rangeMin": -100, "secondaryAxisGroupByDateGranularity": "DAY", "secondaryAxisGroupByFieldMetadataId": Any, - "secondaryAxisGroupBySubFieldName": null, + "secondaryAxisGroupBySubFieldName": "primaryLinkUrl", "secondaryAxisManualSortOrder": null, "secondaryAxisOrderBy": "FIELD_DESC", "timezone": "UTC", @@ -307,7 +307,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Line Chart Widget", "type": "GRAPH", @@ -354,7 +354,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Line Chart Widget Minimal", "type": "GRAPH", @@ -391,7 +391,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Pie Chart Widget", "type": "GRAPH", @@ -428,7 +428,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Pie Chart Widget Minimal", "type": "GRAPH", @@ -466,8 +466,8 @@ exports[`Page layout widget creation should succeed should create a page layout { "configuration": { "body": { - "blocknote": null, - "markdown": "Simple text", + "blocknote": "{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Simple text"}]}]}", + "markdown": "{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Simple text"}]}]}", }, "configurationType": "STANDALONE_RICH_TEXT", }, @@ -528,7 +528,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Vertical Bar Chart Widget", "type": "GRAPH", @@ -576,7 +576,7 @@ exports[`Page layout widget creation should succeed should create a page layout "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Vertical Bar Chart Widget Minimal", "type": "GRAPH", diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/__snapshots__/successful-page-layout-widget-update.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/__snapshots__/successful-page-layout-widget-update.integration-spec.ts.snap index f0ef76cb58..64f16340ec 100644 --- a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/__snapshots__/successful-page-layout-widget-update.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/__snapshots__/successful-page-layout-widget-update.integration-spec.ts.snap @@ -25,7 +25,7 @@ exports[`Page layout widget update should succeed from a GRAPH widget should upd "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Original Graph Widget", "type": "GRAPH", @@ -55,7 +55,7 @@ exports[`Page layout widget update should succeed from a GRAPH widget should upd "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Original Graph Widget", "type": "GRAPH", @@ -103,7 +103,7 @@ exports[`Page layout widget update should succeed from a GRAPH widget should upd "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Original Graph Widget", "type": "GRAPH", @@ -136,7 +136,7 @@ exports[`Page layout widget update should succeed from a GRAPH widget should upd "rangeMin": -100, "secondaryAxisGroupByDateGranularity": "DAY", "secondaryAxisGroupByFieldMetadataId": Any, - "secondaryAxisGroupBySubFieldName": null, + "secondaryAxisGroupBySubFieldName": "primaryLinkUrl", "secondaryAxisManualSortOrder": null, "secondaryAxisOrderBy": "FIELD_DESC", "timezone": "UTC", @@ -150,7 +150,7 @@ exports[`Page layout widget update should succeed from a GRAPH widget should upd "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Original Graph Widget", "type": "GRAPH", @@ -187,7 +187,7 @@ exports[`Page layout widget update should succeed from a GRAPH widget should upd "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Original Graph Widget", "type": "GRAPH", @@ -235,7 +235,7 @@ exports[`Page layout widget update should succeed from a GRAPH widget should upd "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Original Graph Widget", "type": "GRAPH", diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/successful-page-layout-widget-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/successful-page-layout-widget-creation.integration-spec.ts index 129039e736..3642c41ca8 100644 --- a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/successful-page-layout-widget-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/successful-page-layout-widget-creation.integration-spec.ts @@ -5,9 +5,9 @@ import { } from 'test/integration/constants/widget-configuration-test-data.constants'; import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.util'; import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/destroy-one-page-layout-tab.util'; -import { fetchTestFieldMetadataIds } from 'test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.util'; import { createOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/create-one-page-layout-widget.util'; import { destroyOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/destroy-one-page-layout-widget.util'; +import { fetchTestFieldMetadataIds } from 'test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.util'; import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util'; import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util'; import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any'; @@ -105,11 +105,13 @@ describe('Page layout widget creation should succeed', () => { let testSetup: { pageLayoutId: string; pageLayoutTabId: string; + objectMetadataId: string; fieldMetadataId1: string; fieldMetadataId2: string; fieldMetadataId3: string; + fieldMetadataId3SubFieldName: string; }; - let createdPageLayoutWidgetId: string; + let createdPageLayoutWidgetId: string | undefined; const graphTestCases: EachTestingContext[] = [ { @@ -262,6 +264,8 @@ describe('Page layout widget creation should succeed', () => { primaryAxisGroupByFieldMetadataId: testSetup.fieldMetadataId2, primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, secondaryAxisGroupByFieldMetadataId: testSetup.fieldMetadataId3, + secondaryAxisGroupBySubFieldName: + testSetup.fieldMetadataId3SubFieldName, secondaryAxisOrderBy: GraphOrderBy.FIELD_DESC, displayDataLabel: true, axisNameDisplay: AxisNameDisplay.NONE, @@ -358,6 +362,8 @@ describe('Page layout widget creation should succeed', () => { input: { id: createdPageLayoutWidgetId }, }); } + + createdPageLayoutWidgetId = undefined; }); it.each(eachTestingContextFilter(STATIC_TEST_CASES))( @@ -387,6 +393,7 @@ describe('Page layout widget creation should succeed', () => { input: { title: widgetTitle, type: WidgetType.GRAPH, + objectMetadataId: testSetup.objectMetadataId, configuration: buildConfiguration(), pageLayoutTabId: testSetup.pageLayoutTabId, gridPosition: DEFAULT_GRID_POSITION, diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/successful-page-layout-widget-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/successful-page-layout-widget-update.integration-spec.ts index b69c895ea1..fef0e89465 100644 --- a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/successful-page-layout-widget-update.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/successful-page-layout-widget-update.integration-spec.ts @@ -5,9 +5,9 @@ import { } from 'test/integration/constants/widget-configuration-test-data.constants'; import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.util'; import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/destroy-one-page-layout-tab.util'; -import { fetchTestFieldMetadataIds } from 'test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.util'; import { createOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/create-one-page-layout-widget.util'; import { destroyOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/destroy-one-page-layout-widget.util'; +import { fetchTestFieldMetadataIds } from 'test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.util'; import { updateOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/update-one-page-layout-widget.util'; import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util'; import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util'; @@ -86,9 +86,11 @@ describe('Page layout widget update should succeed', () => { let testSetup: { pageLayoutId: string; pageLayoutTabId: string; + objectMetadataId: string; fieldMetadataId1: string; fieldMetadataId2: string; fieldMetadataId3: string; + fieldMetadataId3SubFieldName: string; }; beforeAll(async () => { @@ -301,6 +303,8 @@ describe('Page layout widget update should succeed', () => { primaryAxisGroupByFieldMetadataId: testSetup.fieldMetadataId2, primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, secondaryAxisGroupByFieldMetadataId: testSetup.fieldMetadataId3, + secondaryAxisGroupBySubFieldName: + testSetup.fieldMetadataId3SubFieldName, secondaryAxisOrderBy: GraphOrderBy.FIELD_DESC, displayDataLabel: true, axisNameDisplay: AxisNameDisplay.NONE, @@ -333,6 +337,7 @@ describe('Page layout widget update should succeed', () => { title: 'Original Graph Widget', pageLayoutTabId: testSetup.pageLayoutTabId, type: WidgetType.GRAPH, + objectMetadataId: testSetup.objectMetadataId, configuration: { configurationType: WidgetConfigurationType.AGGREGATE_CHART, aggregateFieldMetadataId: testSetup.fieldMetadataId1, diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.util.ts b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.util.ts index bcc229f03f..6f722a39b7 100644 --- a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.util.ts @@ -2,9 +2,11 @@ import { findManyObjectMetadata } from 'test/integration/metadata/suites/object- import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; export type TestFieldMetadataIds = { + objectMetadataId: string; fieldMetadataId1: string; fieldMetadataId2: string; fieldMetadataId3: string; + fieldMetadataId3SubFieldName: string; }; // Uses well-known company fields that make semantic sense for chart configs: @@ -50,8 +52,10 @@ export const fetchTestFieldMetadataIds = }; return { + objectMetadataId: companyObject.id, fieldMetadataId1: findFieldByName('employees').id, fieldMetadataId2: findFieldByName('name').id, fieldMetadataId3: findFieldByName('domainName').id, + fieldMetadataId3SubFieldName: 'primaryLinkUrl', }; }; diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout/__snapshots__/successful-page-layout-with-tabs-update.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/page-layout/__snapshots__/successful-page-layout-with-tabs-update.integration-spec.ts.snap index 54e06b72d7..c2a69f0e5f 100644 --- a/packages/twenty-server/test/integration/metadata/suites/page-layout/__snapshots__/successful-page-layout-with-tabs-update.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/page-layout/__snapshots__/successful-page-layout-with-tabs-update.integration-spec.ts.snap @@ -45,7 +45,7 @@ exports[`Page layout with tabs update should succeed should update page layout w "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Pie Chart Widget", "type": "GRAPH", @@ -135,7 +135,7 @@ exports[`Page layout with tabs update should succeed should update page layout w "rowSpan": 1, }, "id": Any, - "objectMetadataId": null, + "objectMetadataId": Any, "pageLayoutTabId": Any, "title": "Pie Chart Widget", "type": "GRAPH", diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout/successful-page-layout-with-tabs-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/page-layout/successful-page-layout-with-tabs-update.integration-spec.ts index 26df668b92..08565f57ee 100644 --- a/packages/twenty-server/test/integration/metadata/suites/page-layout/successful-page-layout-with-tabs-update.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/page-layout/successful-page-layout-with-tabs-update.integration-spec.ts @@ -38,7 +38,7 @@ type TestContext = { pageLayoutTabId: string; title: string; type: WidgetType; - objectMetadataId: null; + objectMetadataId: string | null; gridPosition: { row: number; column: number; @@ -52,9 +52,11 @@ type TestContext = { describe('Page layout with tabs update should succeed', () => { let testFieldMetadataIds: { + objectMetadataId: string; fieldMetadataId1: string; fieldMetadataId2: string; fieldMetadataId3: string; + fieldMetadataId3SubFieldName: string; }; let testPageLayoutId: string; let testTabId1: string; @@ -76,7 +78,7 @@ describe('Page layout with tabs update should succeed', () => { pageLayoutTabId: tabId1, title: 'Pie Chart Widget', type: WidgetType.GRAPH, - objectMetadataId: null, + objectMetadataId: testFieldMetadataIds.objectMetadataId, gridPosition: { row: 0, column: 0, @@ -111,7 +113,7 @@ describe('Page layout with tabs update should succeed', () => { pageLayoutTabId: tabId1, title: 'Pie Chart Widget', type: WidgetType.GRAPH, - objectMetadataId: null, + objectMetadataId: testFieldMetadataIds.objectMetadataId, gridPosition: { row: 0, column: 0,