[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:
+162
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
+3
-2
@@ -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(
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+50
@@ -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');
|
||||
});
|
||||
});
|
||||
+91
@@ -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;
|
||||
};
|
||||
+18
@@ -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(' ');
|
||||
};
|
||||
Reference in New Issue
Block a user