feat(ai): add dashboard tools for AI chat (#16517)

## Summary

- Implements real tools for the dashboard-building skill to create and
manage dashboards through the AI chat interface
- Adds 6 new dashboard tools: `create_complete_dashboard`,
`list_dashboards`, `get_dashboard`, `add_dashboard_widget`,
`update_dashboard_widget`, `delete_dashboard_widget`
- Improves widget configuration robustness with typed Zod schemas and
discriminated unions for graph types

## Key Changes

**New Dashboard Tools:**
- `create_complete_dashboard` - Creates a dashboard with layout, tab,
and widgets in a single call
- `list_dashboards` - Lists all dashboards in the workspace
- `get_dashboard` - Gets full dashboard details including tabs and
widget configurations
- `add_dashboard_widget` - Adds a widget to an existing dashboard tab
- `update_dashboard_widget` - Updates widget properties or configuration
- `delete_dashboard_widget` - Removes a widget from a dashboard

**Widget Configuration Improvements:**
- Typed Zod schemas for each chart type (AGGREGATE, BAR, LINE, PIE)
- Discriminated union validation based on `graphType`
- Widget-level error handling for partial success when creating
dashboards
- Clear documentation about required `objectMetadataId` and field UUIDs

**Skill Documentation Updates:**
- Updated `dashboard-building.skill.ts` with critical guidance about
looking up field metadata first
- Added workflow instructions: use `list_object_metadata_items` before
creating GRAPH widgets
- Practical grid layout recommendations

## Test plan

- [ ] Create a new dashboard via AI chat
- [ ] Verify widgets display data correctly when proper field IDs are
provided
- [ ] Test adding/updating/deleting widgets on existing dashboards
- [ ] Verify error messages are helpful when configuration is incorrect
This commit is contained in:
Félix Malfait
2025-12-12 07:35:14 +01:00
committed by GitHub
parent 70a78aafe9
commit 5f4f4c0af8
18 changed files with 976 additions and 55 deletions
@@ -0,0 +1,77 @@
import { z } from 'zod';
import { type WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
import {
gridPositionSchema,
widgetConfigurationSchema,
widgetTypeSchema,
} from 'src/modules/dashboard/tools/schemas/widget.schema';
import {
type DashboardToolContext,
type DashboardToolDependencies,
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
const addDashboardWidgetSchema = z.object({
pageLayoutTabId: z.string().uuid().describe('Tab UUID from get_dashboard'),
title: z.string().describe('Widget title'),
type: widgetTypeSchema.describe('Widget type'),
gridPosition: gridPositionSchema.describe('Position in 12-column grid'),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe('Required for GRAPH widgets: object UUID to aggregate'),
configuration: widgetConfigurationSchema,
});
export const createAddDashboardWidgetTool = (
deps: Pick<DashboardToolDependencies, 'pageLayoutWidgetService'>,
context: DashboardToolContext,
) => ({
name: 'add_dashboard_widget' as const,
description: `Add a widget to an existing dashboard tab.
Use get_dashboard first to get pageLayoutTabId and existing widget positions.
Use list_object_metadata_items to get objectMetadataId and field IDs for GRAPH widgets.
See create_complete_dashboard for configuration examples.`,
inputSchema: addDashboardWidgetSchema,
execute: async (parameters: {
pageLayoutTabId: string;
title: string;
type: WidgetType;
gridPosition: {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
objectMetadataId?: string;
configuration?: Record<string, unknown>;
}) => {
try {
const widget = await deps.pageLayoutWidgetService.create(
parameters,
context.workspaceId,
);
return {
success: true,
message: `Widget "${parameters.title}" added`,
result: {
widgetId: widget.id,
title: widget.title,
type: widget.type,
gridPosition: widget.gridPosition,
pageLayoutTabId: parameters.pageLayoutTabId,
},
};
} catch (error) {
return {
success: false,
message: `Failed to add widget: ${error.message}`,
error: error.message,
};
}
},
});
@@ -0,0 +1,219 @@
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { type WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
gridPositionSchema,
widgetConfigurationSchema,
widgetTypeSchema,
} from 'src/modules/dashboard/tools/schemas/widget.schema';
import {
type DashboardToolContext,
type DashboardToolDependencies,
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
const widgetSchema = z.object({
title: z.string().describe('Widget title displayed in the header'),
type: widgetTypeSchema.describe('Widget type'),
gridPosition: gridPositionSchema.describe('Position in 12-column grid'),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe(
'REQUIRED for GRAPH widgets: UUID of the object to aggregate (e.g., opportunity, company)',
),
configuration: widgetConfigurationSchema,
});
const createCompleteDashboardSchema = z.object({
title: z.string().describe('Dashboard title'),
tabTitle: z
.string()
.optional()
.default('Main')
.describe('Title of the first tab'),
widgets: z
.array(widgetSchema)
.optional()
.default([])
.describe('Widgets to add'),
});
export const createCreateCompleteDashboardTool = (
deps: DashboardToolDependencies,
context: DashboardToolContext,
) => ({
name: 'create_complete_dashboard' as const,
description: `Create a dashboard with layout, tab, and widgets.
IMPORTANT: Before creating GRAPH widgets, you MUST use list_object_metadata_items to get valid objectMetadataId and field IDs.
GRID SYSTEM:
- 12 columns (0-11), rows start at 0
- Full width: columnSpan: 12, Half: columnSpan: 6, Third: columnSpan: 4
- Row spans: 2-4 (KPI), 6-8 (charts)
WIDGET TYPES:
1. GRAPH with graphType "AGGREGATE" (KPI number):
- Requires: objectMetadataId, configuration.graphType, configuration.aggregateFieldMetadataId, configuration.aggregateOperation
- Example: { type: "GRAPH", objectMetadataId: "<opportunity-object-uuid>", configuration: { graphType: "AGGREGATE", aggregateFieldMetadataId: "<amount-field-uuid>", aggregateOperation: "SUM" } }
2. GRAPH with graphType "VERTICAL_BAR" or "HORIZONTAL_BAR":
- Additional required: configuration.primaryAxisGroupByFieldMetadataId
- Example: { graphType: "VERTICAL_BAR", aggregateFieldMetadataId: "<count-field-uuid>", aggregateOperation: "COUNT", primaryAxisGroupByFieldMetadataId: "<stage-field-uuid>" }
3. GRAPH with graphType "LINE":
- Same as bar charts, good for time series
4. GRAPH with graphType "PIE":
- Requires: objectMetadataId, aggregateFieldMetadataId, aggregateOperation, groupByFieldMetadataId
- Example: { graphType: "PIE", aggregateFieldMetadataId: "<id-field-uuid>", aggregateOperation: "COUNT", groupByFieldMetadataId: "<stage-field-uuid>" }
5. IFRAME: { type: "IFRAME", configuration: { url: "https://..." } }
6. STANDALONE_RICH_TEXT: { type: "STANDALONE_RICH_TEXT", configuration: { body: "..." } }
AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY`,
inputSchema: createCompleteDashboardSchema,
execute: async (parameters: {
title: string;
tabTitle?: string;
widgets?: Array<{
title: string;
type: WidgetType;
gridPosition: {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
objectMetadataId?: string;
configuration?: Record<string, unknown>;
}>;
}) => {
try {
const tabTitle = parameters.tabTitle ?? 'Main';
const widgets = parameters.widgets ?? [];
const pageLayout = await deps.pageLayoutService.create(
{ name: parameters.title, type: PageLayoutType.DASHBOARD },
context.workspaceId,
);
const pageLayoutTab = await deps.pageLayoutTabService.create(
{ title: tabTitle, pageLayoutId: pageLayout.id, position: 0 },
context.workspaceId,
);
const createdWidgets = [];
const widgetErrors = [];
for (const widget of widgets) {
try {
const createdWidget = await deps.pageLayoutWidgetService.create(
{ ...widget, pageLayoutTabId: pageLayoutTab.id },
context.workspaceId,
);
createdWidgets.push({
id: createdWidget.id,
title: createdWidget.title,
type: createdWidget.type,
});
} catch (widgetError) {
widgetErrors.push({
title: widget.title,
error: widgetError.message,
});
}
}
const dashboardId = await createDashboardRecord(
deps,
context,
parameters.title,
pageLayout.id,
);
const result = {
dashboardId,
pageLayoutId: pageLayout.id,
pageLayoutTabId: pageLayoutTab.id,
title: parameters.title,
widgets: createdWidgets,
};
if (widgetErrors.length > 0) {
return {
success: true,
message: `Dashboard created with ${createdWidgets.length} widgets. ${widgetErrors.length} widget(s) failed.`,
result,
widgetErrors,
recordReferences: [
{
objectNameSingular: 'dashboard',
recordId: dashboardId,
displayName: parameters.title,
},
],
};
}
return {
success: true,
message: `Dashboard "${parameters.title}" created with ${createdWidgets.length} widgets`,
result,
recordReferences: [
{
objectNameSingular: 'dashboard',
recordId: dashboardId,
displayName: parameters.title,
},
],
};
} catch (error) {
return {
success: false,
message: `Failed to create dashboard: ${error.message}`,
error: error.message,
};
}
},
});
const createDashboardRecord = async (
deps: DashboardToolDependencies,
context: DashboardToolContext,
title: string,
pageLayoutId: string,
): Promise<string> => {
const authContext = buildSystemAuthContext(context.workspaceId);
return deps.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
const dashboardRepository =
await deps.globalWorkspaceOrmManager.getRepository(
context.workspaceId,
'dashboard',
{ shouldBypassPermissionChecks: true },
);
const position = await deps.recordPositionService.buildRecordPosition({
value: 'first',
objectMetadata: { isCustom: false, nameSingular: 'dashboard' },
workspaceId: context.workspaceId,
});
const dashboard = { id: uuidv4(), title, pageLayoutId, position };
await dashboardRepository.insert(dashboard);
return dashboard.id;
},
);
};
@@ -0,0 +1,28 @@
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 { PageLayoutModule } from 'src/engine/metadata-modules/page-layout/page-layout.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { DashboardToolWorkspaceService } from './services/dashboard-tool.workspace-service';
@Global()
@Module({
imports: [
PageLayoutModule,
RecordPositionModule,
TwentyORMModule,
ApplicationModule,
],
providers: [
DashboardToolWorkspaceService,
{
provide: DASHBOARD_TOOL_SERVICE_TOKEN,
useExisting: DashboardToolWorkspaceService,
},
],
exports: [DashboardToolWorkspaceService, DASHBOARD_TOOL_SERVICE_TOKEN],
})
export class DashboardToolsModule {}
@@ -0,0 +1,47 @@
import { z } from 'zod';
import {
type DashboardToolContext,
type DashboardToolDependencies,
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
const deleteDashboardWidgetSchema = z.object({
widgetId: z.string().uuid().describe('The UUID of the widget to delete'),
});
export const createDeleteDashboardWidgetTool = (
deps: Pick<DashboardToolDependencies, 'pageLayoutWidgetService'>,
context: DashboardToolContext,
) => ({
name: 'delete_dashboard_widget' as const,
description: `Delete a widget from a dashboard. Use get_dashboard first to find the widgetId.`,
inputSchema: deleteDashboardWidgetSchema,
execute: async (parameters: { widgetId: string }) => {
try {
const widget = await deps.pageLayoutWidgetService.findByIdOrThrow(
parameters.widgetId,
context.workspaceId,
);
await deps.pageLayoutWidgetService.destroy(
parameters.widgetId,
context.workspaceId,
);
return {
success: true,
message: `Widget "${widget.title}" deleted`,
result: {
deletedWidgetId: parameters.widgetId,
deletedWidgetTitle: widget.title,
},
};
} catch (error) {
return {
success: false,
message: `Failed to delete widget: ${error.message}`,
error: error.message,
};
}
},
});
@@ -0,0 +1,101 @@
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
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';
const getDashboardSchema = z.object({
dashboardId: z.string().uuid().describe('The UUID of the dashboard to fetch'),
});
export const createGetDashboardTool = (
deps: Pick<
DashboardToolDependencies,
'pageLayoutService' | 'globalWorkspaceOrmManager'
>,
context: DashboardToolContext,
) => ({
name: 'get_dashboard' as const,
description: `Get a dashboard with its full layout structure including tabs and widgets.`,
inputSchema: getDashboardSchema,
execute: async (parameters: { dashboardId: string }) => {
try {
const authContext = buildSystemAuthContext(context.workspaceId);
const dashboard =
await deps.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
const repo = await deps.globalWorkspaceOrmManager.getRepository(
context.workspaceId,
'dashboard',
{ shouldBypassPermissionChecks: true },
);
return repo.findOne({ where: { id: parameters.dashboardId } });
},
);
if (!isDefined(dashboard)) {
return {
success: false,
message: `Dashboard "${parameters.dashboardId}" not found`,
error: 'DASHBOARD_NOT_FOUND',
};
}
if (!isDefined(dashboard.pageLayoutId)) {
return {
success: false,
message: `Dashboard "${dashboard.title}" has no page layout`,
error: 'PAGE_LAYOUT_NOT_FOUND',
};
}
const pageLayout = await deps.pageLayoutService.findByIdOrThrow(
dashboard.pageLayoutId,
context.workspaceId,
);
const tabs =
pageLayout.tabs?.map((tab) => ({
id: tab.id,
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,
})) ?? [],
})) ?? [];
return {
success: true,
message: `Retrieved dashboard "${dashboard.title}" with ${tabs.length} tab(s)`,
result: {
dashboard: {
id: dashboard.id,
title: dashboard.title,
pageLayoutId: dashboard.pageLayoutId,
createdAt: dashboard.createdAt,
updatedAt: dashboard.updatedAt,
},
layout: { id: pageLayout.id, name: pageLayout.name, tabs },
},
};
} catch (error) {
return {
success: false,
message: `Failed to get dashboard: ${error.message}`,
error: error.message,
};
}
},
});
@@ -0,0 +1,66 @@
import { z } from 'zod';
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';
const listDashboardsSchema = z.object({
limit: z
.number()
.min(1)
.max(100)
.optional()
.default(20)
.describe('Maximum number of dashboards to return (default: 20, max: 100)'),
});
export const createListDashboardsTool = (
deps: Pick<DashboardToolDependencies, 'globalWorkspaceOrmManager'>,
context: DashboardToolContext,
) => ({
name: 'list_dashboards' as const,
description: `List all dashboards in the workspace. Use get_dashboard to retrieve full layout structure.`,
inputSchema: listDashboardsSchema,
execute: async (parameters: { limit?: number }) => {
try {
const limit = parameters.limit ?? 20;
const authContext = buildSystemAuthContext(context.workspaceId);
const dashboards =
await deps.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
const repo = await deps.globalWorkspaceOrmManager.getRepository(
context.workspaceId,
'dashboard',
{ shouldBypassPermissionChecks: true },
);
return repo.find({ take: limit, order: { position: 'ASC' } });
},
);
const dashboardList = dashboards.map((d) => ({
id: d.id,
title: d.title,
pageLayoutId: d.pageLayoutId,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
}));
return {
success: true,
message: `Found ${dashboardList.length} dashboard(s)`,
result: { dashboards: dashboardList, count: dashboardList.length },
};
} catch (error) {
return {
success: false,
message: `Failed to list dashboards: ${error.message}`,
error: error.message,
};
}
},
});
@@ -0,0 +1,119 @@
import { z } from 'zod';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { GraphType } from 'src/engine/metadata-modules/page-layout/enums/graph-type.enum';
import { WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
export const gridPositionSchema = z.object({
row: z.number().min(0).describe('Row position (0-based)'),
column: z
.number()
.min(0)
.max(11)
.describe('Column position (0-11 for 12-column grid)'),
rowSpan: z.number().min(1).describe('Number of rows the widget spans'),
columnSpan: z
.number()
.min(1)
.max(12)
.describe('Number of columns the widget spans (1-12)'),
});
export const widgetTypeSchema = z.enum([
WidgetType.VIEW,
WidgetType.GRAPH,
WidgetType.IFRAME,
WidgetType.STANDALONE_RICH_TEXT,
]);
// Graph configuration schema for AGGREGATE type (KPI numbers)
const aggregateChartConfigSchema = z.object({
graphType: z.literal(GraphType.AGGREGATE),
aggregateFieldMetadataId: z
.string()
.uuid()
.describe(
'Field UUID to aggregate (must be from the widget objectMetadataId)',
),
aggregateOperation: z
.nativeEnum(AggregateOperations)
.describe('Aggregation operation: COUNT, SUM, AVG, MIN, MAX, etc.'),
displayDataLabel: z.boolean().optional().default(true),
label: z.string().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
filter: z.record(z.string(), z.unknown()).optional(),
});
// Graph configuration schema for BAR charts
const barChartConfigSchema = z.object({
graphType: z.enum([GraphType.VERTICAL_BAR, GraphType.HORIZONTAL_BAR]),
aggregateFieldMetadataId: z
.string()
.uuid()
.describe('Field UUID to aggregate'),
aggregateOperation: z.nativeEnum(AggregateOperations),
primaryAxisGroupByFieldMetadataId: z
.string()
.uuid()
.describe('Field UUID to group by on primary axis'),
secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
primaryAxisOrderBy: z
.enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
.optional(),
displayDataLabel: z.boolean().optional().default(false),
displayLegend: z.boolean().optional().default(true),
filter: z.record(z.string(), z.unknown()).optional(),
});
// Graph configuration schema for LINE charts
const lineChartConfigSchema = z.object({
graphType: z.literal(GraphType.LINE),
aggregateFieldMetadataId: z.string().uuid(),
aggregateOperation: z.nativeEnum(AggregateOperations),
primaryAxisGroupByFieldMetadataId: z.string().uuid(),
secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
primaryAxisOrderBy: z
.enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
.optional(),
displayDataLabel: z.boolean().optional().default(false),
filter: z.record(z.string(), z.unknown()).optional(),
});
// Graph configuration schema for PIE charts
const pieChartConfigSchema = z.object({
graphType: z.literal(GraphType.PIE),
aggregateFieldMetadataId: z.string().uuid(),
aggregateOperation: z.nativeEnum(AggregateOperations),
groupByFieldMetadataId: z.string().uuid().describe('Field UUID to slice by'),
orderBy: z
.enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
.optional(),
displayDataLabel: z.boolean().optional().default(true),
filter: z.record(z.string(), z.unknown()).optional(),
});
// Iframe configuration
const iframeConfigSchema = z.object({
url: z.string().url().describe('URL to embed'),
});
// Rich text configuration
const richTextConfigSchema = z.object({
body: z.string().optional().describe('Rich text content'),
});
export const graphConfigurationSchema = z.discriminatedUnion('graphType', [
aggregateChartConfigSchema,
barChartConfigSchema,
lineChartConfigSchema,
pieChartConfigSchema,
]);
export const widgetConfigurationSchema = z
.union([graphConfigurationSchema, iframeConfigSchema, richTextConfigSchema])
.optional()
.describe('Widget configuration - structure depends on widget type');
// Export enums for documentation
export { AggregateOperations, GraphType };
@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout/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 { 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';
import { createGetDashboardTool } from 'src/modules/dashboard/tools/get-dashboard.tool';
import { createListDashboardsTool } from 'src/modules/dashboard/tools/list-dashboards.tool';
import { type DashboardToolDependencies } from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
import { createUpdateDashboardWidgetTool } from 'src/modules/dashboard/tools/update-dashboard-widget.tool';
@Injectable()
export class DashboardToolWorkspaceService {
private readonly deps: DashboardToolDependencies;
constructor(
pageLayoutService: PageLayoutService,
pageLayoutTabService: PageLayoutTabService,
pageLayoutWidgetService: PageLayoutWidgetService,
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
recordPositionService: RecordPositionService,
applicationService: ApplicationService,
) {
this.deps = {
pageLayoutService,
pageLayoutTabService,
pageLayoutWidgetService,
globalWorkspaceOrmManager,
recordPositionService,
applicationService,
};
}
generateDashboardTools(
workspaceId: string,
_rolePermissionConfig: RolePermissionConfig,
): ToolSet {
const context = { workspaceId };
const createCompleteDashboard = createCreateCompleteDashboardTool(
this.deps,
context,
);
const listDashboards = createListDashboardsTool(this.deps, context);
const getDashboard = createGetDashboardTool(this.deps, context);
const addDashboardWidget = createAddDashboardWidgetTool(this.deps, context);
const updateDashboardWidget = createUpdateDashboardWidgetTool(
this.deps,
context,
);
const deleteDashboardWidget = createDeleteDashboardWidgetTool(
this.deps,
context,
);
return {
[createCompleteDashboard.name]: createCompleteDashboard,
[listDashboards.name]: listDashboards,
[getDashboard.name]: getDashboard,
[addDashboardWidget.name]: addDashboardWidget,
[updateDashboardWidget.name]: updateDashboardWidget,
[deleteDashboardWidget.name]: deleteDashboardWidget,
};
}
}
@@ -0,0 +1,24 @@
import type { ApplicationService } from 'src/engine/core-modules/application/application.service';
import type { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import type { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout/services/page-layout-tab.service';
import type { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout/services/page-layout-widget.service';
import type { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.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';
export type DashboardToolDependencies = {
pageLayoutService: PageLayoutService;
pageLayoutTabService: PageLayoutTabService;
pageLayoutWidgetService: PageLayoutWidgetService;
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager;
recordPositionService: RecordPositionService;
applicationService: ApplicationService;
};
export type DashboardToolContext = {
workspaceId: string;
};
export type DashboardToolContextWithPermissions = DashboardToolContext & {
rolePermissionConfig: RolePermissionConfig;
};
@@ -0,0 +1,85 @@
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { type WidgetType } from 'src/engine/metadata-modules/page-layout/enums/widget-type.enum';
import {
gridPositionSchema,
widgetConfigurationSchema,
widgetTypeSchema,
} from 'src/modules/dashboard/tools/schemas/widget.schema';
import {
type DashboardToolContext,
type DashboardToolDependencies,
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
const updateDashboardWidgetSchema = z.object({
widgetId: z.string().uuid().describe('The UUID of the widget to update'),
title: z.string().optional().describe('New widget title'),
type: widgetTypeSchema.optional().describe('New widget type'),
gridPosition: gridPositionSchema
.optional()
.describe('New position and size in the grid layout'),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe('New object metadata ID'),
configuration: widgetConfigurationSchema,
});
export const createUpdateDashboardWidgetTool = (
deps: Pick<DashboardToolDependencies, 'pageLayoutWidgetService'>,
context: DashboardToolContext,
) => ({
name: 'update_dashboard_widget' as const,
description: `Update an existing widget's properties, position, or configuration.
Use get_dashboard first to find the widgetId.
Only provide fields you want to change - others remain unchanged.`,
inputSchema: updateDashboardWidgetSchema,
execute: async (parameters: {
widgetId: string;
title?: string;
type?: WidgetType;
gridPosition?: {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
objectMetadataId?: string;
configuration?: Record<string, unknown>;
}) => {
try {
const { widgetId, ...updates } = parameters;
const updateData = Object.fromEntries(
Object.entries(updates).filter(([, value]) => isDefined(value)),
);
const widget = await deps.pageLayoutWidgetService.update(
widgetId,
context.workspaceId,
updateData,
);
return {
success: true,
message: `Widget "${widget.title}" updated`,
result: {
widgetId: widget.id,
title: widget.title,
type: widget.type,
gridPosition: widget.gridPosition,
configuration: widget.configuration,
},
};
} catch (error) {
return {
success: false,
message: `Failed to update widget: ${error.message}`,
error: error.message,
};
}
},
});