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

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



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

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
nitin
2026-02-10 19:13:36 +05:30
committed by GitHub
parent 148584c730
commit e995e84621
36 changed files with 1334 additions and 168 deletions
@@ -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();
});
});
@@ -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,
};
}
},
});
@@ -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: "<opportunity-object-uuid>", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "<amount-field-uuid>", aggregateOperation: "COUNT", primaryAxisGroupByFieldMetadataId: "<stage-field-uuid>", 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: "<opportunity-object-uuid>", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "<amount-field-uuid>", aggregateOperation: "COUNT", primaryAxisGroupByFieldMetadataId: "<stage-field-uuid>", layout: "VERTICAL" } }
- Example (relation field): { type: "GRAPH", objectMetadataId: "<opportunity-object-uuid>", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "<amount-field-uuid>", aggregateOperation: "SUM", primaryAxisGroupByFieldMetadataId: "<company-field-uuid>", 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,
@@ -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,
@@ -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<string, FlatFieldMetadata[]>();
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<string, unknown> = {};
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,
};
}
},
@@ -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(
@@ -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,
@@ -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;
@@ -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<string, unknown>;
configuration?: AllPageLayoutWidgetConfiguration;
}) => {
try {
const { widgetId, ...updates } = parameters;
@@ -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');
});
});
@@ -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<FlatFieldMetadata>;
fieldsByObjectId: Map<string, FlatFieldMetadata[]>;
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;
};
@@ -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(' ');
};