diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useRecordFilterField.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useRecordFilterField.ts index be77823ed9..660173291c 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useRecordFilterField.ts +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useRecordFilterField.ts @@ -21,6 +21,9 @@ export const useRecordFilterField = (recordFilterId: string) => { recordFilter?.fieldMetadataId ?? '', ); + const { fieldMetadataItem: relationTargetFieldMetadataItem } = + useFieldMetadataItemById(recordFilter?.relationTargetFieldMetadataId ?? ''); + const { getIcon } = useIcons(); const icon = isDefined(fieldMetadataItem?.icon) @@ -38,9 +41,15 @@ export const useRecordFilterField = (recordFilterId: string) => { ) : ''; + const fieldLabel = fieldMetadataItem?.label ?? ''; + + const baseLabel = isDefined(relationTargetFieldMetadataItem) + ? `${fieldLabel} → ${relationTargetFieldMetadataItem.label}` + : fieldLabel; + const label = isNonEmptyString(subFieldLabel) - ? `${recordFilter?.label} / ${subFieldLabel}` - : (recordFilter?.label ?? ''); + ? `${baseLabel} / ${subFieldLabel}` + : baseLabel; return { label, diff --git a/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts b/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts index ddf6dee1b3..2acbea5e93 100644 --- a/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts +++ b/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts @@ -38,10 +38,11 @@ export const buildMcpServerInstructions = ( ` For complex tasks (workflows, metadata), load the matching skill BEFORE calling tools.`, ` ⚠️ Never call workflow or metadata tools without loading their skill first.`, ``, - `Dashboards (coming soon):`, - ` Building or editing dashboards through the AI is not available yet — it is a coming soon feature.`, - ` If asked to create/build/modify a dashboard, do not attempt it: say AI-assisted dashboards are coming soon,`, - ` and offer alternatives (create views, run analytics with group_by_{objects}, or build workflows).`, + `Dashboards:`, + ` To create/build/modify a dashboard, load the dashboard-building skill first, then resolve metadata and build.`, + ` Once metadata is resolved, emit create_complete_dashboard in the same turn — never stop on a "now let me…" preamble.`, + ` If a referenced field is missing (e.g. lead source, won/lost stage), pick a sensible default and state the assumption instead of stalling.`, + ` Informational dashboard questions ("what is a dashboard?") are NOT build requests — answer directly, do not load skill.`, ``, `Route by intent:`, ` Named entity ("Acme company") → find_many_{objects} to resolve id first, then operate on id`, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant.ts new file mode 100644 index 0000000000..106096d05c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant.ts @@ -0,0 +1,5 @@ +export const METADATA_TOOL_EXCLUDED_FIELD_NAMES = new Set([ + 'searchVector', + 'position', + 'updatedBy', +]); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts index 3cc8443165..5657bcc663 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts @@ -58,11 +58,12 @@ export class ViewToolProvider implements ToolProvider { private async buildToolSet(context: ToolProviderContext): Promise { const workspaceMemberId = context.actorContext?.workspaceMemberId; + const userWorkspaceId = context.userWorkspaceId; const readTools = { ...this.viewToolsFactory.generateReadTools( context.workspaceId, - workspaceMemberId ?? undefined, + userWorkspaceId, workspaceMemberId ?? undefined, ), ...this.viewFieldToolsFactory.generateReadTools(context.workspaceId), @@ -84,7 +85,7 @@ export class ViewToolProvider implements ToolProvider { const writeTools = { ...this.viewToolsFactory.generateWriteTools( context.workspaceId, - workspaceMemberId ?? undefined, + userWorkspaceId, ), ...this.viewFieldToolsFactory.generateWriteTools(context.workspaceId), ...this.viewFilterToolsFactory.generateWriteTools(context.workspaceId), diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts index 94978b2c83..633778ce59 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module'; import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token'; import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider'; +import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/providers/dashboard-tool.provider'; import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider'; import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider'; import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider'; @@ -68,6 +69,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; ToolIndexResolver, ToolExecutorService, ActionToolProvider, + DashboardToolProvider, DatabaseToolProvider, MetadataToolProvider, NavigationMenuItemToolProvider, @@ -90,6 +92,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; viewProvider: ViewToolProvider, webhookProvider: WebhookToolProvider, workflowProvider: WorkflowToolProvider, + dashboardProvider: DashboardToolProvider, ) => [ actionProvider, databaseProvider, @@ -99,6 +102,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; viewProvider, webhookProvider, workflowProvider, + dashboardProvider, ], inject: [ ActionToolProvider, @@ -109,6 +113,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; ViewToolProvider, WebhookToolProvider, WorkflowToolProvider, + DashboardToolProvider, ], }, ToolRegistryService, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts index 6a7f8f2654..b3340d996c 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts @@ -58,7 +58,7 @@ export const createLoadSkillTool = ( label: skill.label, content: skill.content, })), - message: `Loaded ${skills.map((skill) => skill.label).join(', ')}`, + message: `Loaded ${skills.map((skill) => skill.label).join(', ')}.`, }; }, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts index 6040d86a01..97cf026104 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts @@ -20,9 +20,11 @@ Examples: For simple CRUD operations (find/create/update/delete a record), you do NOT need a skill — but you still MUST call \`learn_tools\` first to learn the tool schema, then \`execute_tool\` to run it. -## Dashboards (coming soon) +## Dashboards -Building or editing dashboards through the AI is not available yet — it is a coming soon feature. If the user asks you to create, build, or modify a dashboard, do NOT attempt it: let them know that AI-assisted dashboards are coming soon, and offer the alternatives you can help with today (e.g. creating views, running analytics with \`group_by_*\`, or building workflows). +When the user asks to create, build, or modify a dashboard, load the \`dashboard-building\` skill and follow the Plan → Skill → Learn → Execute flow. + +Intent gate: purely informational dashboard questions (e.g. "what is a dashboard in Twenty?", "how do I export a dashboard?", "can I share a dashboard with a client?") are NOT build requests. Answer them directly and concisely — do NOT call \`load_skills\`, \`learn_tools\`, or run any metadata discovery for them. Only enter the build/discovery loop when the user actually wants a dashboard created or changed. ## Skills vs Tools diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index c7923cbf41..ab236fb6a2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -47,6 +47,7 @@ import { UpdateFieldInput } from './dtos/update-field.input'; TokenModule, WorkspaceCacheStorageModule, FeatureFlagModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, NestjsQueryGraphQLModule.forFeature({ imports: [ NestjsQueryTypeOrmModule.forFeature([ diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts index 9bb470ff5b..2dc4643e43 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts @@ -4,12 +4,14 @@ import { type ToolSet } from 'ai'; import { FieldMetadataType, RelationType } from 'twenty-shared/types'; import { z } from 'zod'; +import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant'; import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util'; import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util'; import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { getObjectMetadataIdByName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; - -const EXCLUDED_FIELD_NAMES = new Set(['searchVector', 'position', 'updatedBy']); +import { isDefined } from 'twenty-shared/utils'; const FIELD_STRIP_WHEN_NULLISH = [ 'options', @@ -26,16 +28,14 @@ const FIELD_STRIP_WHEN_FALSE = ['isLabelSyncedWithName']; const FIELD_STRIP_WHEN_TRUE = ['isUIEditable']; const GetFieldMetadataInputSchema = z.object({ - id: z + id: z.uuid().optional().describe('Field ID. Returns one field if set.'), + objectMetadataId: z.uuid().optional().describe('Filter by object ID.'), + objectName: z .string() - .uuid() .optional() - .describe('Field ID. Returns one field if set.'), - objectMetadataId: z - .string() - .uuid() - .optional() - .describe('Filter by object ID.'), + .describe( + 'Filter by object name, singular or plural (e.g. "opportunity" or "opportunities"). Convenient alternative to objectMetadataId so you do not need to resolve the object id first.', + ), includeFullSystemFields: z .boolean() .default(false) @@ -134,7 +134,36 @@ const CreateManyRelationFieldsInputSchema = z.object({ @Injectable() export class FieldMetadataToolsFactory { - constructor(private readonly fieldMetadataService: FieldMetadataService) {} + constructor( + private readonly fieldMetadataService: FieldMetadataService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + ) {} + + private async getObjectMetadataIdOrThrow( + workspaceId: string, + objectName: string, + ): Promise { + const { flatObjectMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps'], + }, + ); + + const objectMetadataId = getObjectMetadataIdByName({ + flatObjectMetadataMaps, + objectName, + }); + + if (!isDefined(objectMetadataId)) { + throw new Error( + `Object "${objectName}" not found. Use get_object_metadata to list available objects.`, + ); + } + + return objectMetadataId; + } generateTools(workspaceId: string): ToolSet { return { @@ -145,16 +174,26 @@ export class FieldMetadataToolsFactory { execute: async (parameters: { id?: string; objectMetadataId?: string; + objectName?: string; includeFullSystemFields?: boolean; limit?: number; }) => { + const objectMetadataId = + parameters.objectMetadataId ?? + (parameters.objectName + ? await this.getObjectMetadataIdOrThrow( + workspaceId, + parameters.objectName, + ) + : undefined); + const rawResults = await this.fieldMetadataService.query({ filter: { workspaceId: { eq: workspaceId }, ...(parameters.id ? { id: { eq: parameters.id } } : {}), - ...(parameters.objectMetadataId + ...(isDefined(objectMetadataId) ? { - objectMetadataId: { eq: parameters.objectMetadataId }, + objectMetadataId: { eq: objectMetadataId }, } : {}), }, @@ -164,7 +203,10 @@ export class FieldMetadataToolsFactory { const compactedFields = ( rawResults as unknown as Record[] ) - .filter((field) => !EXCLUDED_FIELD_NAMES.has(field.name as string)) + .filter( + (field) => + !METADATA_TOOL_EXCLUDED_FIELD_NAMES.has(field.name as string), + ) .map((field) => { if (field.isSystem && !parameters.includeFullSystemFields) { return { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/get-invalid-select-filter-option-values.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/get-invalid-select-filter-option-values.util.spec.ts new file mode 100644 index 0000000000..b132e61c7e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/get-invalid-select-filter-option-values.util.spec.ts @@ -0,0 +1,103 @@ +import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types'; + +import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { getInvalidSelectFilterOptionValues } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util'; + +const OBJECT_ID = '11111111-1111-4111-8111-111111111111'; + +const selectField = getFlatFieldMetadataMock({ + id: '33333333-3333-4333-8333-333333333333', + universalIdentifier: '33333333-3333-4333-8333-333333333333', + objectMetadataId: OBJECT_ID, + type: FieldMetadataType.SELECT, + name: 'stage', + label: 'Stage', + options: [ + { id: 'opt-won', color: 'green', label: 'Won', value: 'WON', position: 0 }, + { id: 'opt-lost', color: 'red', label: 'Lost', value: 'LOST', position: 1 }, + ], +}) as FlatFieldMetadata; + +describe('getInvalidSelectFilterOptionValues', () => { + it('returns invalid values for a SELECT field when an option does not exist', () => { + expect( + getInvalidSelectFilterOptionValues({ + fieldMetadata: selectField, + operand: ViewFilterOperand.IS, + value: ['WON', 'NOT_A_REAL_OPTION'], + }), + ).toEqual(['NOT_A_REAL_OPTION']); + }); + + it('returns an empty array when all SELECT values are valid options', () => { + expect( + getInvalidSelectFilterOptionValues({ + fieldMetadata: selectField, + operand: ViewFilterOperand.IS, + value: ['WON', 'LOST'], + }), + ).toEqual([]); + }); + + it('supports a plain string array value', () => { + expect( + getInvalidSelectFilterOptionValues({ + fieldMetadata: selectField, + operand: ViewFilterOperand.IS, + value: ['NOPE'], + }), + ).toEqual(['NOPE']); + }); + + it('parses a JSON-stringified array value (chart filter format)', () => { + expect( + getInvalidSelectFilterOptionValues({ + fieldMetadata: selectField, + operand: ViewFilterOperand.IS, + value: '["WON"]', + }), + ).toEqual([]); + }); + + it('parses a JSON-stringified multi-value array and reports invalid options', () => { + expect( + getInvalidSelectFilterOptionValues({ + fieldMetadata: selectField, + operand: ViewFilterOperand.IS, + value: '["WON","NOPE"]', + }), + ).toEqual(['NOPE']); + }); + + it('treats a non-JSON plain string as a single option value', () => { + expect( + getInvalidSelectFilterOptionValues({ + fieldMetadata: selectField, + operand: ViewFilterOperand.IS, + value: 'WON', + }), + ).toEqual([]); + }); + + it('ignores value-less operands like IS_EMPTY', () => { + expect( + getInvalidSelectFilterOptionValues({ + fieldMetadata: selectField, + operand: ViewFilterOperand.IS_EMPTY, + value: '', + }), + ).toEqual([]); + }); + + it('ignores subfield filters', () => { + expect( + getInvalidSelectFilterOptionValues({ + fieldMetadata: selectField, + operand: ViewFilterOperand.IS, + subFieldName: 'someSubField', + value: JSON.stringify(['NOPE']), + }), + ).toEqual([]); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util.ts new file mode 100644 index 0000000000..66797596b3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util.ts @@ -0,0 +1,4 @@ +export const buildFieldByObjectIdAndNameKey = ( + objectMetadataId: string, + fieldName: string, +): string => `${objectMetadataId}:${fieldName}`; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-id-by-name-maps.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-id-by-name-maps.util.ts new file mode 100644 index 0000000000..ece07e724c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/build-field-id-by-name-maps.util.ts @@ -0,0 +1,38 @@ +import { type FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +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 { buildFieldByObjectIdAndNameKey } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util'; + +export const buildFieldIdByNameMaps = ( + flatFieldMetadataMaps: FlatEntityMaps, +): { + fieldIdByObjectIdAndName: Map; + fieldById: Map; +} => { + const fieldIdByObjectIdAndName = new Map(); + const fieldById = new Map(); + + for (const fieldMetadata of Object.values( + flatFieldMetadataMaps.byUniversalIdentifier, + )) { + if (!isDefined(fieldMetadata)) { + continue; + } + + fieldIdByObjectIdAndName.set( + buildFieldByObjectIdAndNameKey( + fieldMetadata.objectMetadataId, + fieldMetadata.name, + ), + fieldMetadata.id, + ); + + fieldById.set(fieldMetadata.id, { + type: fieldMetadata.type, + }); + } + + return { fieldIdByObjectIdAndName, fieldById }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts new file mode 100644 index 0000000000..17584705ab --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts @@ -0,0 +1,72 @@ +import { isArray, isNonEmptyString } from '@sniptt/guards'; +import { FieldMetadataType, type ViewFilterOperand } from 'twenty-shared/types'; +import { + isDefined, + isRecordFilterOperandExpectingValue, +} from 'twenty-shared/utils'; + +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type'; + +const normalizeSelectFilterValues = ( + value: ViewFilterValue | null, +): string[] => { + if (isArray(value)) { + return value.filter(isNonEmptyString); + } + + if (!isNonEmptyString(value)) { + return []; + } + + try { + const parsedValue: unknown = JSON.parse(value); + + if (isArray(parsedValue)) { + return parsedValue.filter(isNonEmptyString); + } + } catch { + // Not a JSON-stringified array — treat the raw string as a single value. + } + + return [value]; +}; + +export const getInvalidSelectFilterOptionValues = ({ + fieldMetadata, + operand, + subFieldName, + value, +}: { + fieldMetadata: Pick< + FlatFieldMetadata< + FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT + >, + 'type' | 'options' + >; + operand: ViewFilterOperand; + subFieldName?: string | null; + value: ViewFilterValue | null; +}): string[] => { + if (!isRecordFilterOperandExpectingValue(operand)) { + return []; + } + + if (isNonEmptyString(subFieldName)) { + return []; + } + + const filterValues = normalizeSelectFilterValues(value); + + if (filterValues.length === 0) { + return []; + } + + if (!isDefined(fieldMetadata.options)) { + return filterValues; + } + + return filterValues.filter((filterValue) => + fieldMetadata.options.every((option) => option.value !== filterValue), + ); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util.ts new file mode 100644 index 0000000000..4bd7213e58 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util.ts @@ -0,0 +1,17 @@ +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; + +export const getObjectMetadataIdByName = ({ + flatObjectMetadataMaps, + objectName, +}: { + flatObjectMetadataMaps: FlatEntityMaps; + objectName: string; +}): string | undefined => { + const { idByNameSingular, idByNamePlural } = buildObjectIdByNameMaps( + flatObjectMetadataMaps, + ); + + return idByNameSingular[objectName] ?? idByNamePlural[objectName]; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-view/utils/build-complete-view-children-flat-operations.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-view/utils/build-complete-view-children-flat-operations.util.ts new file mode 100644 index 0000000000..d92242d249 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-view/utils/build-complete-view-children-flat-operations.util.ts @@ -0,0 +1,161 @@ +import { + type ViewFilterOperand, + type ViewSortDirection, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { type FlatEntityToCreateDeleteUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-to-create-delete-update.type'; +import { fromCreateViewFieldInputToFlatViewFieldToCreate } from 'src/engine/metadata-modules/flat-view-field/utils/from-create-view-field-input-to-flat-view-field-to-create.util'; +import { fromCreateViewFilterInputToFlatViewFilterToCreate } from 'src/engine/metadata-modules/flat-view-filter/utils/from-create-view-filter-input-to-flat-view-filter-to-create.util'; +import { fromCreateViewSortInputToFlatViewSortToCreate } from 'src/engine/metadata-modules/flat-view-sort/utils/from-create-view-sort-input-to-flat-view-sort-to-create.util'; +import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type'; + +// Resolved children specs (field names already resolved to fieldMetadataId) +export type CompleteViewFieldSpec = { + fieldMetadataId: string; + isVisible: boolean; + size: number; +}; + +export type CompleteViewFilterSpec = { + fieldMetadataId: string; + operand: ViewFilterOperand; + value: ViewFilterValue; + subFieldName?: string; +}; + +export type CompleteViewSortSpec = { + fieldMetadataId: string; + direction: ViewSortDirection; +}; + +type BuildCompleteViewChildrenFlatOperationsArgs = { + viewId: string; + flatApplication: FlatApplication; + fields?: CompleteViewFieldSpec[]; + filters?: CompleteViewFilterSpec[]; + sorts?: CompleteViewSortSpec[]; +} & Pick< + AllFlatEntityMaps, + | 'flatFieldMetadataMaps' + | 'flatViewMaps' + | 'flatViewFieldMaps' + | 'flatViewFilterMaps' + | 'flatViewSortMaps' + | 'flatViewFieldGroupMaps' + | 'flatViewFilterGroupMaps' +>; + +type CompleteViewChildrenFlatOperations = { + viewField?: FlatEntityToCreateDeleteUpdate<'viewField'>; + viewFilter?: FlatEntityToCreateDeleteUpdate<'viewFilter'>; + viewSort?: FlatEntityToCreateDeleteUpdate<'viewSort'>; +}; + +export const buildCompleteViewChildrenFlatOperations = ({ + viewId, + flatApplication, + flatFieldMetadataMaps, + flatViewMaps, + flatViewFieldMaps, + flatViewFilterMaps, + flatViewSortMaps, + flatViewFieldGroupMaps, + flatViewFilterGroupMaps, + fields, + filters, + sorts, +}: BuildCompleteViewChildrenFlatOperationsArgs): CompleteViewChildrenFlatOperations => { + const operations: CompleteViewChildrenFlatOperations = {}; + + if (isDefined(fields)) { + const existingFlatViewFields = Object.values( + flatViewFieldMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((flatViewField) => flatViewField.viewId === viewId); + + const flatViewFieldsToCreate = fields.map((field, index) => + fromCreateViewFieldInputToFlatViewFieldToCreate({ + createViewFieldInput: { + viewId, + fieldMetadataId: field.fieldMetadataId, + isVisible: field.isVisible, + size: field.size, + position: index, + }, + flatApplication, + flatFieldMetadataMaps, + flatViewMaps, + flatViewFieldGroupMaps, + }), + ); + + operations.viewField = { + flatEntityToCreate: flatViewFieldsToCreate, + flatEntityToDelete: existingFlatViewFields, + flatEntityToUpdate: [], + }; + } + + if (isDefined(filters)) { + const existingFlatViewFilters = Object.values( + flatViewFilterMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((flatViewFilter) => flatViewFilter.viewId === viewId); + + const flatViewFiltersToCreate = filters.map((filter) => + fromCreateViewFilterInputToFlatViewFilterToCreate({ + createViewFilterInput: { + viewId, + fieldMetadataId: filter.fieldMetadataId, + operand: filter.operand, + value: filter.value, + subFieldName: filter.subFieldName, + }, + flatApplication, + flatFieldMetadataMaps, + flatViewMaps, + flatViewFilterGroupMaps, + }), + ); + + operations.viewFilter = { + flatEntityToCreate: flatViewFiltersToCreate, + flatEntityToDelete: existingFlatViewFilters, + flatEntityToUpdate: [], + }; + } + + if (isDefined(sorts)) { + const existingFlatViewSorts = Object.values( + flatViewSortMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((flatViewSort) => flatViewSort.viewId === viewId); + + const flatViewSortsToCreate = sorts.map((sort) => + fromCreateViewSortInputToFlatViewSortToCreate({ + createViewSortInput: { + viewId, + fieldMetadataId: sort.fieldMetadataId, + direction: sort.direction, + }, + flatApplication, + flatFieldMetadataMaps, + flatViewMaps, + }), + ); + + operations.viewSort = { + flatEntityToCreate: flatViewSortsToCreate, + flatEntityToDelete: existingFlatViewSorts, + flatEntityToUpdate: [], + }; + } + + return operations; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts index c525adb460..63a538ac87 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts @@ -9,8 +9,8 @@ import { import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; -import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @@ -48,6 +48,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace FeatureFlagModule, ApplicationModule, ApplicationTranslationModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, NestjsQueryGraphQLModule.forFeature({ imports: [ TypeORMModule, diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index 09536993d4..47ca8267ba 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -989,12 +989,19 @@ export class ObjectMetadataService extends TypeOrmQueryService, ): Promise { + const whereWithWorkspaceId = Array.isArray(options?.where) + ? options.where.map((whereCondition) => ({ + ...whereCondition, + workspaceId, + })) + : { + ...options?.where, + workspaceId, + }; + const objectMetadataEntities = await this.objectMetadataRepository.find({ ...options, - where: { - ...options?.where, - workspaceId, - }, + where: whereWithWorkspaceId, order: { ...options?.order, }, diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts index 1679a807b9..820d0caf83 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts @@ -1,14 +1,25 @@ import { Injectable } from '@nestjs/common'; import { type ToolSet } from 'ai'; +import { type FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { z } from 'zod'; +import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant'; import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util'; import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; +type InlinedObjectFieldSummary = { + id: string; + name: string; + type: FieldMetadataType; + label: string; +}; + const OBJECT_STRIP_WHEN_NULLISH = [ 'overrides', 'color', @@ -20,11 +31,19 @@ const OBJECT_STRIP_WHEN_NULLISH = [ ]; const GetObjectMetadataInputSchema = z.object({ - id: z + id: z.uuid().optional().describe('Object ID. Returns one object if set.'), + objectName: z .string() - .uuid() .optional() - .describe('Object ID. Returns one object if set.'), + .describe( + 'Filter by object name, singular or plural (e.g. "opportunity" or "opportunities"). Lets you locate an object by name in one call without scanning the full list.', + ), + includeFields: z + .boolean() + .default(false) + .describe( + 'When true, each returned object includes its fields as a compact array of {id, name, type, label}. Use this to fetch an object and all the field ids you need in a single call (e.g. before building a dashboard) instead of a separate get_field_metadata call.', + ), includeFullSystemObjects: z .boolean() .default(false) @@ -103,16 +122,59 @@ const UpdateManyObjectMetadataInputSchema = z.object({ @Injectable() export class ObjectMetadataToolsFactory { - constructor(private readonly objectMetadataService: ObjectMetadataService) {} + constructor( + private readonly objectMetadataService: ObjectMetadataService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + ) {} + + private async buildFieldsByObjectId( + workspaceId: string, + ): Promise> { + const { flatFieldMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatFieldMetadataMaps'], + }, + ); + + const fieldsByObjectId = new Map(); + + for (const fieldMetadata of Object.values( + flatFieldMetadataMaps.byUniversalIdentifier, + )) { + if ( + !isDefined(fieldMetadata) || + METADATA_TOOL_EXCLUDED_FIELD_NAMES.has(fieldMetadata.name) + ) { + continue; + } + + const existing = + fieldsByObjectId.get(fieldMetadata.objectMetadataId) ?? []; + + existing.push({ + id: fieldMetadata.id, + name: fieldMetadata.name, + type: fieldMetadata.type, + label: fieldMetadata.label ?? fieldMetadata.name, + }); + fieldsByObjectId.set(fieldMetadata.objectMetadataId, existing); + } + + return fieldsByObjectId; + } generateTools(workspaceId: string): ToolSet { return { get_object_metadata: { description: - "List object metadata as an array. System objects are returned as compact {id, nameSingular, namePlural} — enough to locate an object by name and read its id. Keep includeFullSystemObjects at its default (false); only set it true when you specifically need a system object's full configuration.", + "List object metadata as an array. Filter to a single object by id or objectName (singular or plural). Set includeFields to also return each object's fields ({id, name, type, label}) — enough to build a dashboard or view without a separate get_field_metadata call. System objects are otherwise returned as compact {id, nameSingular, namePlural}. Keep includeFullSystemObjects at its default (false); only set it true when you specifically need a system object's full configuration.", inputSchema: GetObjectMetadataInputSchema, execute: async (parameters: { id?: string; + objectName?: string; + includeFields?: boolean; includeFullSystemObjects?: boolean; limit?: number; }) => { @@ -120,25 +182,44 @@ export class ObjectMetadataToolsFactory { await this.objectMetadataService.findManyWithinWorkspace( workspaceId, { - ...(parameters.id ? { where: { id: parameters.id } } : {}), + ...(parameters.id + ? { where: { id: parameters.id } } + : parameters.objectName + ? { + where: [ + { nameSingular: parameters.objectName }, + { namePlural: parameters.objectName }, + ], + } + : {}), take: parameters.limit ?? 100, }, ); + const fieldsByObjectId = parameters.includeFields + ? await this.buildFieldsByObjectId(workspaceId) + : undefined; + return flatObjectMetadatas.map((flatObjectMetadata) => { const dto = fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata); + const fields = fieldsByObjectId?.get(dto.id) ?? []; + if (dto.isSystem && !parameters.includeFullSystemObjects) { return { id: dto.id, nameSingular: dto.nameSingular, namePlural: dto.namePlural, + ...(parameters.includeFields ? { fields } : {}), }; } return compactMetadataOutput( - { ...dto }, + { + ...dto, + ...(parameters.includeFields ? { fields } : {}), + }, { stripWhenNullish: OBJECT_STRIP_WHEN_NULLISH }, ); }); 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 index 293a5c04f1..c8e6cc68e6 100644 --- 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 @@ -1,16 +1,23 @@ import { msg } from '@lingui/core/macro'; +import { + FieldMetadataType, + type ChartRecordFilter, + type ViewFilterOperand, +} from 'twenty-shared/types'; 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 { getInvalidSelectFilterOptionValues } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util'; +import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util'; 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 { PageLayoutWidgetFieldValidationException } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget-field-validation.exception'; import { PageLayoutWidgetException, PageLayoutWidgetExceptionCode, } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception'; -import { PageLayoutWidgetFieldValidationException } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget-field-validation.exception'; 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 { isChartReferencingFieldInConfiguration } from 'src/engine/metadata-modules/page-layout-widget/utils/is-chart-referencing-field-in-configuration.util'; @@ -47,6 +54,45 @@ const validateGroupByFieldAsChartFieldOrThrow = ( } }; +const validateSelectFilterOptionsOrThrow = ({ + recordFilter, + filterField, + widgetTitle, +}: { + recordFilter: ChartRecordFilter; + filterField: FlatFieldMetadata< + FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT + >; + widgetTitle?: string | null; +}): void => { + if (!isDefined(recordFilter.value)) { + return; + } + + const invalidValues = getInvalidSelectFilterOptionValues({ + fieldMetadata: filterField, + operand: recordFilter.operand as ViewFilterOperand, + value: recordFilter.value, + }); + + if (invalidValues.length === 0) { + return; + } + + const invalidValuesText = invalidValues + .map((value) => `"${value}"`) + .join(', '); + const allowedValuesText = filterField.options + ?.map((option) => option.value) + .map((optionValue) => `"${optionValue}"`) + .join(', '); + + throw buildChartFieldValidationException( + `Filter on "${filterField.label}" uses option(s) ${invalidValuesText} that do not exist. Allowed values: ${allowedValuesText}.`, + widgetTitle, + ); +}; + export const validateChartConfigurationFieldReferencesOrThrow = ({ widgetConfiguration, widgetObjectMetadataId, @@ -211,6 +257,17 @@ export const validateChartConfigurationFieldReferencesOrThrow = ({ widgetTitle, ); } + + if ( + isFlatFieldMetadataOfType(filterField, FieldMetadataType.SELECT) || + isFlatFieldMetadataOfType(filterField, FieldMetadataType.MULTI_SELECT) + ) { + validateSelectFilterOptionsOrThrow({ + recordFilter, + filterField, + widgetTitle, + }); + } } } }; diff --git a/packages/twenty-server/src/engine/metadata-modules/view/tools/__tests__/view-tools.factory.spec.ts b/packages/twenty-server/src/engine/metadata-modules/view/tools/__tests__/view-tools.factory.spec.ts index 39d0b995a1..0558c4243e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/tools/__tests__/view-tools.factory.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/tools/__tests__/view-tools.factory.spec.ts @@ -10,6 +10,9 @@ import { import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service'; +import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service'; +import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service'; +import { CompleteViewUpsertService } from 'src/engine/metadata-modules/view/tools/services/complete-view-upsert.service'; import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service'; import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory'; @@ -17,9 +20,9 @@ import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-to describe('ViewToolsFactory', () => { let viewToolsFactory: ViewToolsFactory; let viewService: jest.Mocked; + let completeViewUpsertService: jest.Mocked; let viewFieldService: jest.Mocked; let viewQueryParamsService: jest.Mocked; - let _flatEntityMapsCacheService: jest.Mocked; const mockWorkspaceId = 'workspace-id'; const mockUserWorkspaceId = 'user-workspace-id'; @@ -99,15 +102,40 @@ describe('ViewToolsFactory', () => { findByWorkspaceId: jest.fn(), findByObjectMetadataId: jest.fn(), findById: jest.fn(), + findByIdWithRelations: jest.fn(), createOne: jest.fn(), updateOne: jest.fn(), deleteOne: jest.fn(), }, }, + { + provide: CompleteViewUpsertService, + useValue: { + upsertCompleteView: jest.fn(), + }, + }, { provide: ViewFieldService, useValue: { createMany: jest.fn().mockResolvedValue([]), + findByViewId: jest.fn().mockResolvedValue([]), + deleteOne: jest.fn(), + }, + }, + { + provide: ViewFilterService, + useValue: { + createOne: jest.fn(), + findByViewId: jest.fn().mockResolvedValue([]), + deleteOne: jest.fn(), + }, + }, + { + provide: ViewSortService, + useValue: { + createOne: jest.fn(), + findByViewId: jest.fn().mockResolvedValue([]), + deleteOne: jest.fn(), }, }, { @@ -130,11 +158,9 @@ describe('ViewToolsFactory', () => { viewToolsFactory = module.get(ViewToolsFactory); viewService = module.get(ViewService); + completeViewUpsertService = module.get(CompleteViewUpsertService); viewFieldService = module.get(ViewFieldService); viewQueryParamsService = module.get(ViewQueryParamsService); - _flatEntityMapsCacheService = module.get( - WorkspaceManyOrAllFlatEntityMapsCacheService, - ); }); it('should be defined', () => { @@ -579,6 +605,239 @@ describe('ViewToolsFactory', () => { }); }); + describe('upsert_complete_view tool', () => { + it('should be generated alongside the other write tools', () => { + const tools = viewToolsFactory.generateWriteTools(mockWorkspaceId); + + expect(tools).toHaveProperty('upsert_complete_view'); + expect(tools['upsert_complete_view']).toHaveProperty('description'); + expect(tools['upsert_complete_view']).toHaveProperty('inputSchema'); + expect(tools['upsert_complete_view']).toHaveProperty('execute'); + }); + + it('should create a view with fields, filters, and sorts referenced by name in a single upsert call', async () => { + completeViewUpsertService.upsertCompleteView.mockResolvedValue({ + id: 'new-view-id', + name: 'Pipeline', + objectMetadataId: mockObjectMetadataId, + type: ViewType.TABLE, + icon: 'IconList', + visibility: ViewVisibility.WORKSPACE, + viewFields: [{}, {}], + viewFilters: [{}], + viewSorts: [{}], + } as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + const result = await callExecute(tools['upsert_complete_view'], { + objectNameSingular: mockObjectNameSingular, + name: 'Pipeline', + type: ViewType.TABLE, + fields: [{ fieldName: 'name' }, { fieldName: 'stage' }], + filters: [{ fieldName: 'stage', operand: 'IS_NOT', value: ['WON'] }], + sorts: [{ fieldName: 'name', direction: 'DESC' }], + }); + + expect(viewService.createOne).not.toHaveBeenCalled(); + expect( + completeViewUpsertService.upsertCompleteView, + ).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: mockWorkspaceId, + userWorkspaceId: mockUserWorkspaceId, + existingViewId: undefined, + objectMetadataId: mockObjectMetadataId, + name: 'Pipeline', + type: ViewType.TABLE, + fields: [ + { + fieldMetadataId: mockNameFieldMetadataId, + isVisible: true, + size: 150, + }, + { + fieldMetadataId: mockStageFieldMetadataId, + isVisible: true, + size: 150, + }, + ], + filters: [ + { + fieldMetadataId: mockStageFieldMetadataId, + operand: 'IS_NOT', + value: ['WON'], + subFieldName: undefined, + }, + ], + sorts: [ + { fieldMetadataId: mockNameFieldMetadataId, direction: 'DESC' }, + ], + }), + ); + expect(result).toEqual({ + id: 'new-view-id', + name: 'Pipeline', + objectMetadataId: mockObjectMetadataId, + type: ViewType.TABLE, + icon: 'IconList', + visibility: ViewVisibility.WORKSPACE, + fieldCount: 2, + filterCount: 1, + sortCount: 1, + }); + }); + + it('should accept a field referenced by fieldMetadataId without name resolution', async () => { + completeViewUpsertService.upsertCompleteView.mockResolvedValue({ + id: 'new-view-id', + name: 'By Id', + objectMetadataId: mockObjectMetadataId, + type: ViewType.TABLE, + icon: 'IconList', + visibility: ViewVisibility.WORKSPACE, + viewFields: [{}], + } as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + await callExecute(tools['upsert_complete_view'], { + objectNameSingular: mockObjectNameSingular, + name: 'By Id', + fields: [{ fieldMetadataId: mockStageFieldMetadataId }], + }); + + expect( + completeViewUpsertService.upsertCompleteView, + ).toHaveBeenCalledWith( + expect.objectContaining({ + fields: [ + { + fieldMetadataId: mockStageFieldMetadataId, + isVisible: true, + size: 150, + }, + ], + }), + ); + }); + + it('should require objectNameSingular when creating', async () => { + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + await expect( + callExecute(tools['upsert_complete_view'], { + name: 'Missing object', + fields: [{ fieldName: 'name' }], + }), + ).rejects.toThrow('objectNameSingular is required'); + }); + + it('should delegate filter replacement to upsertCompleteView when updating', async () => { + const existingView = { + ...mockView, + visibility: ViewVisibility.WORKSPACE, + }; + + viewService.findById.mockResolvedValue(existingView as any); + completeViewUpsertService.upsertCompleteView.mockResolvedValue({ + ...existingView, + viewFilters: [{}], + } as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + await callExecute(tools['upsert_complete_view'], { + id: mockViewId, + filters: [{ fieldName: 'stage', operand: 'IS', value: ['WON'] }], + }); + + expect( + completeViewUpsertService.upsertCompleteView, + ).toHaveBeenCalledWith( + expect.objectContaining({ + existingViewId: mockViewId, + objectMetadataId: mockObjectMetadataId, + filters: [ + { + fieldMetadataId: mockStageFieldMetadataId, + operand: 'IS', + value: ['WON'], + subFieldName: undefined, + }, + ], + }), + ); + expect(viewService.createOne).not.toHaveBeenCalled(); + }); + + it('should pass an empty sorts array through to upsertCompleteView on update', async () => { + const existingView = { + ...mockView, + visibility: ViewVisibility.WORKSPACE, + }; + + viewService.findById.mockResolvedValue(existingView as any); + completeViewUpsertService.upsertCompleteView.mockResolvedValue({ + ...existingView, + viewSorts: [], + } as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + await callExecute(tools['upsert_complete_view'], { + id: mockViewId, + sorts: [], + }); + + expect( + completeViewUpsertService.upsertCompleteView, + ).toHaveBeenCalledWith( + expect.objectContaining({ + existingViewId: mockViewId, + sorts: [], + }), + ); + }); + + it('should reject updating another users unlisted view', async () => { + const existingView = { + ...mockView, + visibility: ViewVisibility.UNLISTED, + createdByUserWorkspaceId: 'other-user-workspace-id', + }; + + viewService.findById.mockResolvedValue(existingView as any); + + const tools = viewToolsFactory.generateWriteTools( + mockWorkspaceId, + mockUserWorkspaceId, + ); + + await expect( + callExecute(tools['upsert_complete_view'], { + id: mockViewId, + name: 'Updated', + }), + ).rejects.toThrow('You can only update your own unlisted views'); + }); + }); + describe('delete-view tool', () => { it('should delete a workspace view', async () => { const existingView = { diff --git a/packages/twenty-server/src/engine/metadata-modules/view/tools/services/complete-view-upsert.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/tools/services/complete-view-upsert.service.ts new file mode 100644 index 0000000000..0aba980f14 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view/tools/services/complete-view-upsert.service.ts @@ -0,0 +1,374 @@ +import { Injectable } from '@nestjs/common'; + +import { t } from '@lingui/core/macro'; +import { + AggregateOperations, + ViewCalendarLayout, + ViewType, + ViewVisibility, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { type FlatEntityToCreateDeleteUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-to-create-delete-update.type'; +import { + buildCompleteViewChildrenFlatOperations, + type CompleteViewFieldSpec, + type CompleteViewFilterSpec, + type CompleteViewSortSpec, +} from 'src/engine/metadata-modules/flat-view/utils/build-complete-view-children-flat-operations.util'; +import { fromCreateViewInputToFlatViewToCreate } from 'src/engine/metadata-modules/flat-view/utils/from-create-view-input-to-flat-view-to-create.util'; +import { fromUpdateViewInputToFlatViewToUpdateOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-update-view-input-to-flat-view-to-update-or-throw.util'; +import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto'; +import { + ViewException, + ViewExceptionCode, +} from 'src/engine/metadata-modules/view/exceptions/view.exception'; +import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; +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'; + +type ViewUpsertFlatEntityOperations = { + view?: FlatEntityToCreateDeleteUpdate<'view'>; + viewGroup?: FlatEntityToCreateDeleteUpdate<'viewGroup'>; + viewField?: FlatEntityToCreateDeleteUpdate<'viewField'>; + viewFilter?: FlatEntityToCreateDeleteUpdate<'viewFilter'>; + viewSort?: FlatEntityToCreateDeleteUpdate<'viewSort'>; +}; + +type ViewUpsertRootOperations = Pick< + ViewUpsertFlatEntityOperations, + 'view' | 'viewGroup' +> & { + viewId: string; + viewUniversalIdentifier?: string; +}; + +// Backs the AI `upsert_complete_view` tool: upserts a view together with its +// fields, filters and sorts in a single workspace migration. +@Injectable() +export class CompleteViewUpsertService { + constructor( + private readonly viewService: ViewService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly applicationService: ApplicationService, + ) {} + + async upsertCompleteView({ + workspaceId, + userWorkspaceId, + existingViewId, + objectMetadataId, + name, + icon, + type, + visibility, + mainGroupByFieldMetadataId, + kanbanAggregateOperation, + kanbanAggregateOperationFieldMetadataId, + calendarLayout, + calendarFieldMetadataId, + fields, + filters, + sorts, + }: { + workspaceId: string; + userWorkspaceId?: string; + existingViewId?: string; + objectMetadataId?: string; + name?: string; + icon?: string; + type?: ViewType; + visibility?: ViewVisibility; + mainGroupByFieldMetadataId?: string; + kanbanAggregateOperation?: AggregateOperations; + kanbanAggregateOperationFieldMetadataId?: string; + calendarLayout?: ViewCalendarLayout; + calendarFieldMetadataId?: string; + fields?: CompleteViewFieldSpec[]; + filters?: CompleteViewFilterSpec[]; + sorts?: CompleteViewSortSpec[]; + }): Promise { + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + + const applicationUniversalIdentifier = + workspaceCustomFlatApplication.universalIdentifier; + + const { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + flatViewMaps, + flatViewGroupMaps, + flatViewFieldMaps, + flatViewFilterMaps, + flatViewSortMaps, + flatViewFieldGroupMaps, + flatViewFilterGroupMaps, + } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: [ + 'flatFieldMetadataMaps', + 'flatObjectMetadataMaps', + 'flatViewMaps', + 'flatViewGroupMaps', + 'flatViewFieldMaps', + 'flatViewFilterMaps', + 'flatViewSortMaps', + 'flatViewFieldGroupMaps', + 'flatViewFilterGroupMaps', + ], + }, + ); + + const isCreatingView = !isDefined(existingViewId); + + const rootOperations = isDefined(existingViewId) + ? this.buildUpdateViewRootOperationsOrThrow({ + existingViewId, + name, + icon, + userWorkspaceId, + applicationUniversalIdentifier, + flatViewMaps, + flatViewGroupMaps, + flatFieldMetadataMaps, + }) + : this.buildCreateViewRootOperationsOrThrow({ + objectMetadataId, + name, + icon, + type, + visibility, + mainGroupByFieldMetadataId, + kanbanAggregateOperation, + kanbanAggregateOperationFieldMetadataId, + calendarLayout, + calendarFieldMetadataId, + userWorkspaceId, + flatApplication: workspaceCustomFlatApplication, + flatFieldMetadataMaps, + flatObjectMetadataMaps, + }); + + const { viewId, viewUniversalIdentifier } = rootOperations; + + const flatViewMapsForChildren = + isCreatingView && isDefined(viewUniversalIdentifier) + ? { + ...flatViewMaps, + universalIdentifierById: { + ...flatViewMaps.universalIdentifierById, + [viewId]: viewUniversalIdentifier, + }, + } + : flatViewMaps; + + const childrenOperations = buildCompleteViewChildrenFlatOperations({ + viewId, + flatApplication: workspaceCustomFlatApplication, + flatFieldMetadataMaps, + flatViewMaps: flatViewMapsForChildren, + flatViewFieldMaps, + flatViewFilterMaps, + flatViewSortMaps, + flatViewFieldGroupMaps, + flatViewFilterGroupMaps, + fields, + filters, + sorts, + }); + + const allFlatEntityOperationByMetadataName: ViewUpsertFlatEntityOperations = + { + ...(isDefined(rootOperations.view) + ? { view: rootOperations.view } + : {}), + ...(isDefined(rootOperations.viewGroup) + ? { viewGroup: rootOperations.viewGroup } + : {}), + ...(isDefined(childrenOperations.viewField) + ? { viewField: childrenOperations.viewField } + : {}), + ...(isDefined(childrenOperations.viewFilter) + ? { viewFilter: childrenOperations.viewFilter } + : {}), + ...(isDefined(childrenOperations.viewSort) + ? { viewSort: childrenOperations.viewSort } + : {}), + }; + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName, + workspaceId, + isSystemBuild: false, + applicationUniversalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while upserting complete view', + ); + } + + const view = await this.viewService.findByIdWithRelations( + viewId, + workspaceId, + ); + + if (!isDefined(view)) { + throw new ViewException( + t`View not found after upsert`, + ViewExceptionCode.VIEW_NOT_FOUND, + ); + } + + return view; + } + + private buildCreateViewRootOperationsOrThrow({ + objectMetadataId, + name, + icon, + type, + visibility, + mainGroupByFieldMetadataId, + kanbanAggregateOperation, + kanbanAggregateOperationFieldMetadataId, + calendarLayout, + calendarFieldMetadataId, + userWorkspaceId, + flatApplication, + flatFieldMetadataMaps, + flatObjectMetadataMaps, + }: { + objectMetadataId?: string; + name?: string; + icon?: string; + type?: ViewType; + visibility?: ViewVisibility; + mainGroupByFieldMetadataId?: string; + kanbanAggregateOperation?: AggregateOperations; + kanbanAggregateOperationFieldMetadataId?: string; + calendarLayout?: ViewCalendarLayout; + calendarFieldMetadataId?: string; + userWorkspaceId?: string; + flatApplication: FlatApplication; + flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps']; + flatObjectMetadataMaps: AllFlatEntityMaps['flatObjectMetadataMaps']; + }): ViewUpsertRootOperations { + if (!isDefined(objectMetadataId)) { + throw new ViewException( + t`ObjectMetadataId is required when creating a view`, + ViewExceptionCode.INVALID_VIEW_DATA, + ); + } + + const { flatViewToCreate, flatViewGroupsToCreate } = + fromCreateViewInputToFlatViewToCreate({ + createViewInput: { + name: name ?? 'Untitled view', + objectMetadataId, + icon: icon ?? 'IconList', + type: type ?? ViewType.TABLE, + visibility: visibility ?? ViewVisibility.WORKSPACE, + mainGroupByFieldMetadataId, + kanbanAggregateOperation, + kanbanAggregateOperationFieldMetadataId, + calendarLayout, + calendarFieldMetadataId, + }, + createdByUserWorkspaceId: userWorkspaceId, + flatApplication, + flatFieldMetadataMaps, + flatObjectMetadataMaps, + }); + + return { + viewId: flatViewToCreate.id, + viewUniversalIdentifier: flatViewToCreate.universalIdentifier, + view: { + flatEntityToCreate: [flatViewToCreate], + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + ...(flatViewGroupsToCreate.length > 0 + ? { + viewGroup: { + flatEntityToCreate: flatViewGroupsToCreate, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + } + : {}), + }; + } + + private buildUpdateViewRootOperationsOrThrow({ + existingViewId, + name, + icon, + userWorkspaceId, + applicationUniversalIdentifier, + flatViewMaps, + flatViewGroupMaps, + flatFieldMetadataMaps, + }: { + existingViewId: string; + name?: string; + icon?: string; + userWorkspaceId?: string; + applicationUniversalIdentifier: string; + flatViewMaps: AllFlatEntityMaps['flatViewMaps']; + flatViewGroupMaps: AllFlatEntityMaps['flatViewGroupMaps']; + flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps']; + }): ViewUpsertRootOperations { + if (!isDefined(name) && !isDefined(icon)) { + return { viewId: existingViewId }; + } + + const { flatViewToUpdate, flatViewGroupsToDelete, flatViewGroupsToCreate } = + fromUpdateViewInputToFlatViewToUpdateOrThrow({ + updateViewInput: { id: existingViewId, name, icon }, + flatViewMaps, + flatViewGroupMaps, + flatFieldMetadataMaps, + userWorkspaceId, + callerApplicationUniversalIdentifier: applicationUniversalIdentifier, + workspaceCustomApplicationUniversalIdentifier: + applicationUniversalIdentifier, + }); + + return { + viewId: existingViewId, + view: { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: [flatViewToUpdate], + }, + ...(flatViewGroupsToCreate.length > 0 || flatViewGroupsToDelete.length > 0 + ? { + viewGroup: { + flatEntityToCreate: flatViewGroupsToCreate, + flatEntityToDelete: flatViewGroupsToDelete, + flatEntityToUpdate: [], + }, + } + : {}), + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts index 86ff590c21..8ffe45a5a5 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts @@ -5,6 +5,8 @@ import { AggregateOperations, FieldMetadataType, ViewCalendarLayout, + ViewFilterOperand, + ViewSortDirection, ViewType, ViewVisibility, } from 'twenty-shared/types'; @@ -12,13 +14,19 @@ import { z } from 'zod'; import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service'; +import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type'; +import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; +import { CompleteViewUpsertService } from 'src/engine/metadata-modules/view/tools/services/complete-view-upsert.service'; import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service'; import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; -import { isFieldMetadataDateKind, isNonEmptyArray } from 'twenty-shared/utils'; +import { + isDefined, + isFieldMetadataDateKind, + isNonEmptyArray, +} from 'twenty-shared/utils'; const GetViewsInputSchema = z.object({ objectNameSingular: z @@ -64,7 +72,9 @@ const CreateViewInputSchema = z.object({ .enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED]) .optional() .default(ViewVisibility.WORKSPACE) - .describe('View visibility'), + .describe( + 'View visibility. ALWAYS prefer WORKSPACE (the default) — it is the right choice for shared views and for any view backing a dashboard widget, so the view is visible to everyone. Only use UNLISTED for a private, personal view explicitly requested by a single user, AND only when a user identity is available. An UNLISTED view created without an owner becomes invisible to everyone (e.g. its dashboard widget renders blank), so never use UNLISTED for widget-backing views.', + ), mainGroupByFieldName: z .string() .optional() @@ -115,10 +125,160 @@ const DeleteViewInputSchema = z.object({ id: z.string().uuid().describe('View ID to delete'), }); +const VIEW_FILTER_OPERAND_OPTIONS = Object.values(ViewFilterOperand); +const VIEW_SORT_DIRECTION_OPTIONS = Object.values(ViewSortDirection); + +const fieldReferenceShape = { + fieldName: z + .string() + .optional() + .describe( + 'Field name (e.g. "amount", "stage"). Resolved to a UUID server-side. Provide this or fieldMetadataId.', + ), + fieldMetadataId: z + .uuid() + .optional() + .describe( + 'Field UUID. Alternative to fieldName; takes precedence when both are given.', + ), +}; + +const UpsertCompleteViewFieldSchema = z.object({ + ...fieldReferenceShape, + isVisible: z + .boolean() + .optional() + .default(true) + .describe('Whether the column is visible. Defaults to true.'), + size: z + .number() + .int() + .optional() + .default(150) + .describe('Column width. Defaults to 150.'), +}); + +const UpsertCompleteViewFilterSchema = z.object({ + ...fieldReferenceShape, + operand: z + .enum(VIEW_FILTER_OPERAND_OPTIONS) + .describe( + 'Filter operator. Must be valid for the field type (e.g. SELECT: IS, IS_NOT; CURRENCY: GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL; TEXT: CONTAINS).', + ), + value: z + .union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.string()), + z.record(z.string(), z.unknown()), + ]) + .describe( + 'Filter value. Array of option values for SELECT/MULTI_SELECT (e.g. ["WON"]), number for NUMBER/CURRENCY, "" for IS_EMPTY/IS_NOT_EMPTY.', + ), + subFieldName: z + .string() + .optional() + .describe( + 'Required for composite fields (e.g. "amountMicros" for CURRENCY, "addressCity" for ADDRESS, "firstName" for FULL_NAME).', + ), +}); + +const UpsertCompleteViewSortSchema = z.object({ + ...fieldReferenceShape, + direction: z + .enum(VIEW_SORT_DIRECTION_OPTIONS) + .optional() + .default(ViewSortDirection.ASC) + .describe('Sort direction: ASC or DESC. Defaults to ASC.'), +}); + +const UpsertCompleteViewInputSchema = z.object({ + id: z + .uuid() + .optional() + .describe('View ID to update. Omit to create a new view.'), + objectNameSingular: z + .string() + .optional() + .describe( + 'Object name this view is for (e.g. "opportunity"). Required when creating (no id); ignored when id is given.', + ), + name: z.string().optional().describe('View name'), + icon: z.string().optional().describe('Icon identifier (e.g. "IconList")'), + type: z + .enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR]) + .optional() + .describe('View type. Defaults to TABLE on create.'), + visibility: z + .enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED]) + .optional() + .describe( + 'View visibility. Defaults to WORKSPACE on create, which is almost always the best fit — it makes the view visible to everyone and is REQUIRED for any view backing a dashboard widget. Only set UNLISTED for a private, personal view a specific user explicitly asked for, AND only when a user identity is available. An UNLISTED view created without an owner is invisible to everyone (its dashboard widget renders blank), so never use UNLISTED for widget-backing views.', + ), + mainGroupByFieldName: z + .string() + .optional() + .describe( + 'Field name to group by (required for KANBAN, must be a SELECT field, e.g. "stage").', + ), + kanbanAggregateOperation: z + .enum(Object.values(AggregateOperations) as [string, ...string[]]) + .optional() + .describe('Aggregate operation for kanban columns (e.g. "SUM", "COUNT").'), + kanbanAggregateOperationFieldName: z + .string() + .optional() + .describe('Field name for the kanban aggregate operation (e.g. "amount").'), + calendarLayout: z + .enum([ + ViewCalendarLayout.DAY, + ViewCalendarLayout.WEEK, + ViewCalendarLayout.MONTH, + ]) + .optional() + .describe('Calendar layout (required for CALENDAR).'), + calendarFieldName: z + .string() + .optional() + .describe( + 'Date field name for the calendar (required for CALENDAR, must be DATE or DATE_TIME).', + ), + fields: z + .array(UpsertCompleteViewFieldSchema) + .optional() + .describe( + 'Declarative list of columns, in display order. Provided array REPLACES all existing fields; [] clears them; omit to leave untouched.', + ), + filters: z + .array(UpsertCompleteViewFilterSchema) + .optional() + .describe( + 'Declarative list of filters. Provided array REPLACES all existing filters; [] clears them; omit to leave untouched.', + ), + sorts: z + .array(UpsertCompleteViewSortSchema) + .optional() + .describe( + 'Declarative list of sorts. Provided array REPLACES all existing sorts; [] clears them; omit to leave untouched.', + ), +}); + +type FieldReference = { fieldName?: string; fieldMetadataId?: string }; + +type UpsertCompleteViewIdentifiers = { + existingViewId?: string; + objectMetadataId: string; + mainGroupByFieldMetadataId?: string; + kanbanAggregateOperationFieldMetadataId?: string; + calendarFieldMetadataId?: string; +}; + @Injectable() export class ViewToolsFactory { constructor( private readonly viewService: ViewService, + private readonly completeViewUpsertService: CompleteViewUpsertService, private readonly viewFieldService: ViewFieldService, private readonly viewQueryParamsService: ViewQueryParamsService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, @@ -253,6 +413,138 @@ export class ViewToolsFactory { return fieldMetadata.id; } + private async getFieldMetadataIdOrThrow( + workspaceId: string, + objectMetadataId: string, + reference: FieldReference, + ): Promise { + if (isDefined(reference.fieldMetadataId)) { + return reference.fieldMetadataId; + } + + if (isDefined(reference.fieldName)) { + return this.resolveFieldMetadataId( + workspaceId, + objectMetadataId, + reference.fieldName, + ); + } + + throw new Error( + 'Each field, filter, and sort entry must provide either fieldName or fieldMetadataId.', + ); + } + + private async resolveUpsertCompleteViewIdentifiersOrThrow({ + parameters, + workspaceId, + userWorkspaceId, + }: { + parameters: { + id?: string; + objectNameSingular?: string; + type?: ViewType; + mainGroupByFieldName?: string; + kanbanAggregateOperationFieldName?: string; + calendarLayout?: ViewCalendarLayout; + calendarFieldName?: string; + }; + workspaceId: string; + userWorkspaceId?: string; + }): Promise { + if (isDefined(parameters.id)) { + const existingView = await this.viewService.findById( + parameters.id, + workspaceId, + ); + + if (!existingView) { + throw new Error(`View with id ${parameters.id} not found`); + } + + if ( + existingView.visibility === ViewVisibility.UNLISTED && + existingView.createdByUserWorkspaceId !== userWorkspaceId + ) { + throw new Error('You can only update your own unlisted views'); + } + + return { + existingViewId: existingView.id, + objectMetadataId: existingView.objectMetadataId, + }; + } + + if (!isDefined(parameters.objectNameSingular)) { + throw new Error( + 'objectNameSingular is required when creating a view (no id provided).', + ); + } + + const objectMetadataId = await this.resolveObjectMetadataId( + workspaceId, + parameters.objectNameSingular, + ); + + if ( + parameters.type === ViewType.KANBAN && + !isDefined(parameters.mainGroupByFieldName) + ) { + throw new Error( + 'KANBAN views require mainGroupByFieldName. Provide a SELECT field name (e.g. "stage").', + ); + } + + if (parameters.type === ViewType.CALENDAR) { + if (!isDefined(parameters.calendarFieldName)) { + throw new Error( + 'CALENDAR views require calendarFieldName (a DATE or DATE_TIME field name).', + ); + } + + if (!isDefined(parameters.calendarLayout)) { + throw new Error( + 'CALENDAR views require calendarLayout. Provide one of: "DAY", "WEEK", "MONTH".', + ); + } + } + + const mainGroupByFieldMetadataId = isDefined( + parameters.mainGroupByFieldName, + ) + ? await this.resolveGroupByFieldMetadataId( + workspaceId, + objectMetadataId, + parameters.mainGroupByFieldName, + ) + : undefined; + + const kanbanAggregateOperationFieldMetadataId = isDefined( + parameters.kanbanAggregateOperationFieldName, + ) + ? await this.resolveFieldMetadataId( + workspaceId, + objectMetadataId, + parameters.kanbanAggregateOperationFieldName, + ) + : undefined; + + const calendarFieldMetadataId = isDefined(parameters.calendarFieldName) + ? await this.resolveCalendarFieldMetadataId( + workspaceId, + objectMetadataId, + parameters.calendarFieldName, + ) + : undefined; + + return { + objectMetadataId, + mainGroupByFieldMetadataId, + kanbanAggregateOperationFieldMetadataId, + calendarFieldMetadataId, + }; + } + generateReadTools( workspaceId: string, userWorkspaceId?: string, @@ -317,6 +609,139 @@ export class ViewToolsFactory { generateWriteTools(workspaceId: string, userWorkspaceId?: string): ToolSet { return { + upsert_complete_view: { + description: `Create or update a complete view — the view plus its fields (columns), filters, and sorts — in a single call. + +IDENTITY: Omit "id" to CREATE a new view (requires objectNameSingular). Provide "id" to UPDATE an existing view. + +FIELD REFERENCES: In fields/filters/sorts you can reference a field by NAME (fieldName, e.g. "amount") or by UUID (fieldMetadataId). Names are resolved server-side, so you usually do NOT need get_field_metadata first. UUID wins when both are given. + +DECLARATIVE CHILDREN (replace semantics): fields, filters, and sorts each describe the FULL desired set. +- A provided array REPLACES all existing entries of that kind (existing ones are deleted, the new ones created in order). +- An empty array [] CLEARS all entries of that kind. +- Omitting the key leaves existing entries untouched. +This means you never need to fetch child ids to edit a view — just pass the desired end state. For surgical single-entry edits, the granular tools (create_view_filter, update_view_sort, etc.) remain available. + +VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT field), CALENDAR (requires calendarFieldName + calendarLayout).`, + inputSchema: UpsertCompleteViewInputSchema, + execute: async (parameters: { + id?: string; + objectNameSingular?: string; + name?: string; + icon?: string; + type?: ViewType; + visibility?: ViewVisibility; + mainGroupByFieldName?: string; + kanbanAggregateOperation?: AggregateOperations; + kanbanAggregateOperationFieldName?: string; + calendarLayout?: ViewCalendarLayout; + calendarFieldName?: string; + fields?: Array< + FieldReference & { isVisible?: boolean; size?: number } + >; + filters?: Array< + FieldReference & { + operand: ViewFilterOperand; + value: ViewFilterValue; + subFieldName?: string; + } + >; + sorts?: Array; + }) => { + try { + const { + existingViewId, + objectMetadataId, + mainGroupByFieldMetadataId, + kanbanAggregateOperationFieldMetadataId, + calendarFieldMetadataId, + } = await this.resolveUpsertCompleteViewIdentifiersOrThrow({ + parameters, + workspaceId, + userWorkspaceId, + }); + + const fields = isDefined(parameters.fields) + ? await Promise.all( + parameters.fields.map(async (field) => ({ + fieldMetadataId: await this.getFieldMetadataIdOrThrow( + workspaceId, + objectMetadataId, + field, + ), + isVisible: field.isVisible ?? true, + size: field.size ?? 150, + })), + ) + : undefined; + + const filters = isDefined(parameters.filters) + ? await Promise.all( + parameters.filters.map(async (filter) => ({ + fieldMetadataId: await this.getFieldMetadataIdOrThrow( + workspaceId, + objectMetadataId, + filter, + ), + operand: filter.operand, + value: filter.value, + subFieldName: filter.subFieldName, + })), + ) + : undefined; + + const sorts = isDefined(parameters.sorts) + ? await Promise.all( + parameters.sorts.map(async (sort) => ({ + fieldMetadataId: await this.getFieldMetadataIdOrThrow( + workspaceId, + objectMetadataId, + sort, + ), + direction: sort.direction ?? ViewSortDirection.ASC, + })), + ) + : undefined; + + const view = + await this.completeViewUpsertService.upsertCompleteView({ + workspaceId, + userWorkspaceId, + existingViewId, + objectMetadataId, + name: parameters.name, + icon: parameters.icon, + type: parameters.type, + visibility: parameters.visibility, + mainGroupByFieldMetadataId, + kanbanAggregateOperation: parameters.kanbanAggregateOperation, + kanbanAggregateOperationFieldMetadataId, + calendarLayout: parameters.calendarLayout, + calendarFieldMetadataId, + fields, + filters, + sorts, + }); + + return { + id: view.id, + name: view.name, + objectMetadataId, + type: view.type, + icon: view.icon, + visibility: view.visibility, + fieldCount: view.viewFields?.length ?? 0, + filterCount: view.viewFilters?.length ?? 0, + sortCount: view.viewSorts?.length ?? 0, + }; + } catch (error) { + if (error instanceof WorkspaceMigrationBuilderException) { + throw new Error(formatValidationErrors(error)); + } + throw error; + } + }, + }, create_view: { description: 'Create a new view for an object. Views define how records are displayed. For KANBAN views, mainGroupByFieldName is required and must be a SELECT field (e.g., "stage", "status"). For CALENDAR views, calendarFieldName and calendarLayout are required.', diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts b/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts index 32477875f8..422542874b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts @@ -12,6 +12,7 @@ import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-fie import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module'; import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module'; import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module'; +import { CompleteViewUpsertService } from 'src/engine/metadata-modules/view/tools/services/complete-view-upsert.service'; import { ViewWidgetUpsertService } from 'src/engine/metadata-modules/view/services/view-widget-upsert.service'; import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; @@ -43,6 +44,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace controllers: [ViewController], providers: [ ViewService, + CompleteViewUpsertService, ViewResolver, ViewQueryParamsService, ViewToolsFactory, 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 0e41318466..d2a2dc5330 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 @@ -425,14 +425,38 @@ You help users create and manage dashboards with widgets. - add_dashboard_tab, add_dashboard_widget, update_dashboard_widget, delete_dashboard_widget - get_object_metadata / get_field_metadata (resolve object + field IDs) -## Graph Widget Workflow +## Confirmation gate (ALWAYS ask before creating or updating) -1. Ask what data the user wants to visualize. -2. Call get_object_metadata and get_field_metadata to 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. +Before calling ANY tool that creates or modifies a dashboard (\`create_complete_dashboard\`, \`add_dashboard_tab\`, \`add_dashboard_widget\`, \`update_dashboard_widget\`, \`delete_dashboard_widget\`), you MUST first present a short plan and get explicit user confirmation. + +- Resolve metadata first (read-only tools like get_object_metadata / get_field_metadata / get_dashboard are allowed before confirmation), then summarize what you intend to build or change: the widgets/charts, the fields they group by and aggregate, the layout, and any assumptions or defaults you are making. +- Ask the user to confirm (or adjust) and then STOP and wait for their answer. Do NOT call any creation/update tool in the same turn as the plan. +- Only after the user confirms do you proceed to build/modify in the next turn. +- Keep the plan concise — a few bullets, not an essay. The goal is a quick "yes, go ahead" or a correction, not a lengthy back-and-forth. + +## Build Workflow (creating a new dashboard, AFTER confirmation) + +Once the user has confirmed the plan, your job is to deliver the dashboard in that turn. + +1. Resolve metadata in as few calls as possible: call get_object_metadata and get_field_metadata for the relevant object(s) once, and batch field lookups. Do NOT re-fetch metadata you already have in context. +2. Build the full widget configuration using the rules below. +3. **Emit \`create_complete_dashboard\` with ALL widgets in a single call.** Prefer one-shot \`create_complete_dashboard\` over building the dashboard incrementally with multiple \`add_dashboard_widget\` calls — fewer round-trips means lower cost and fewer points to stall. +4. After the tool returns success, confirm to the user what was built (and restate any assumptions you made). + +### Completion guard (critical, applies once confirmed) + +- After the user has confirmed, you MUST call the appropriate dashboard tool for the requested change in that turn (e.g. \`create_complete_dashboard\`, \`add_dashboard_widget\`, \`update_dashboard_widget\`, or \`delete_dashboard_widget\`). **Never end your turn on a "now let me…" / "I'll build this…" preamble without actually calling the tool.** A preamble with no following tool call (after confirmation) is a failure. +- Do NOT yield or hand back to the user until at least one dashboard tool has returned success — unless you are still waiting on confirmation or genuinely blocked on something only the user can answer. + +### Default-and-proceed (resolve defaults in the plan, do not stall) + +- If the request references a field or concept that does not exist in the workspace (e.g. "Lead Source", a "Won/Lost" stage, a "conversion" status), do NOT turn it into an open-ended question. Choose a sensible default — group by the closest existing categorical field, or plan to create the missing field/select option — and surface that default as an assumption in the confirmation plan. +- Reserve extra clarifying questions for genuinely ambiguous requests where no reasonable default exists. Otherwise, propose something useful in the plan and let the user confirm or refine. + +## Modifying an existing dashboard + +- Call get_dashboard first to read the current layout, then present the intended changes and get confirmation (see the confirmation gate above) before calling add_dashboard_widget / update_dashboard_widget / delete_dashboard_widget. Use activeTabId from context if available. +- Only call get_dashboard when modifying — never before creating a brand-new dashboard. ## Field Resolution Rules @@ -483,13 +507,15 @@ You help users create and manage dashboards with widgets. - 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 - RECORD_TABLE: configurationType "RECORD_TABLE" — displays a filterable, sortable record list - - **MANDATORY 3-step pre-sequence before creating the widget**: - 1. call create_view (type TABLE, name e.g. "Repairs Dashboard Table") → get the new viewId - 2. call create_many_view_fields on the new viewId — add 4–6 of the most relevant fields (label identifier + key SELECT/DATE/CURRENCY fields). Use positions 0, 1, 2… and isVisible: true. - 3. call create_many_view_filters and/or create_view_sort on the new viewId to focus the table (e.g. filter out DONE/CANCELLED records, sort by createdAt DESC or a date field ASC) + - **MANDATORY: create the dedicated view in ONE call before creating the widget**: + - Call \`upsert_complete_view\` once with the view plus its fields, filters, and sorts. Do NOT use the separate create_view / create_many_view_fields / create_many_view_filters / create_view_sort calls — that is several round-trips where one suffices. + - Reference fields by NAME (fieldName) — you generally do not need field UUIDs. Pass fieldMetadataId only if you already have it. + - Include 4–6 of the most relevant fields (label identifier + key SELECT/DATE/CURRENCY fields). Order in the array IS the column order. + - Add filters/sorts to focus the table (e.g. filter out DONE/CANCELLED records, sort by a date field). - Never reuse a record index view — widget views and record index views must be separate + - Leave the view's visibility as WORKSPACE (the default) — never set UNLISTED on a widget-backing view, or the widget will render a blank table - Set objectMetadataId on the widget (top-level, required) - - Set configuration.viewId to the UUID of the dedicated view (required) + - Set configuration.viewId to the UUID returned by upsert_complete_view (required) - columnSpan 12 (full width) or 6 (half width), rowSpan 6–10 Example (STANDALONE_RICH_TEXT): @@ -498,12 +524,16 @@ Example (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." } } -Example (RECORD_TABLE — always run the 3-step pre-sequence first): -Step 1 — create_view: { "name": "Active Repairs", "objectNameSingular": "repair", "type": "TABLE" } → { "id": "" } -Step 2 — create_many_view_fields: { "viewFields": [{ "viewId": "", "fieldMetadataId": "", "position": 1, "isVisible": true }, { "viewId": "", "fieldMetadataId": "", "position": 2, "isVisible": true }] } -Step 3 — create_many_view_filters: { "filters": [{ "viewId": "", "fieldMetadataId": "", "operand": "IS_NOT", "value": "DONE" }] } -Step 3b — create_view_sort: { "viewId": "", "fieldMetadataId": "", "direction": "DESC" } -Step 4 — add_dashboard_widget: { "type": "RECORD_TABLE", "objectMetadataId": "", "configuration": { "configurationType": "RECORD_TABLE", "viewId": "" }, "gridPosition": { "row": 0, "column": 0, "rowSpan": 8, "columnSpan": 12 } } +Example (RECORD_TABLE — one view call, then the widget): +Step 1 — upsert_complete_view: { + "name": "Active Repairs", + "objectNameSingular": "repair", + "type": "TABLE", + "fields": [{ "fieldName": "name" }, { "fieldName": "status" }, { "fieldName": "amount" }], + "filters": [{ "fieldName": "status", "operand": "IS_NOT", "value": ["DONE"] }], + "sorts": [{ "fieldName": "createdAt", "direction": "DESC" }] +} → { "id": "" } +Step 2 — add_dashboard_widget: { "type": "RECORD_TABLE", "objectMetadataId": "", "configuration": { "configurationType": "RECORD_TABLE", "viewId": "" }, "gridPosition": { "row": 0, "column": 0, "rowSpan": 8, "columnSpan": 12 } } ## Tabs @@ -528,10 +558,6 @@ After creating a tab, use its returned tabId as pageLayoutTabId when calling add - When modifying a chart, confirm whether the user wants to change settings or change chart type - Use RECORD_TABLE widgets to give users direct access to filtered record lists without leaving the dashboard`, isCustom: false, - // Dashboard tools are temporarily disabled in AI chat / MCP because the - // generated dashboards are not reliable yet. Keeping the skill defined - // (inactive) so it can be re-enabled once the tooling is trustworthy. - isActive: false, }, }), @@ -1268,16 +1294,27 @@ You help users create and configure views to organize how they see their records ## Tools +- **upsert_complete_view** - Create OR update a view together with its fields, filters, and sorts in a single call. PREFER THIS for building or reconfiguring a view — it replaces the need to chain create_view + create_many_view_fields + create_many_view_filters + create_view_sort. - get_views - List existing views (filter by object name) -- create_view - Create a new view -- update_view - Update view name/icon +- create_view - Create a new view (low-level; prefer upsert_complete_view) +- update_view - Update view name/icon (low-level; prefer upsert_complete_view) - delete_view - Delete a view -- create_many_view_fields - Add visible columns to a view +- create_many_view_fields - Add visible columns to a view (low-level; prefer upsert_complete_view) - update_many_view_fields - Update column configuration - get_view_fields - List columns in a view - get_object_metadata / get_field_metadata - Discover objects and their fields - navigate_app - Navigate to a view after creation +## upsert_complete_view (preferred) + +One call builds or reconfigures an entire view: +- Omit \`id\` to CREATE (requires \`objectNameSingular\`); provide \`id\` to UPDATE an existing view. +- Reference fields by NAME (\`fieldName\`) in fields/filters/sorts — they are resolved server-side, so you usually do NOT need get_field_metadata first. You may pass \`fieldMetadataId\` instead when you already have the UUID. +- \`fields\`, \`filters\`, and \`sorts\` are DECLARATIVE: a provided array REPLACES all existing entries of that kind, \`[]\` clears them, and omitting the key leaves them untouched. So to edit a view you just pass the desired end state — no need to fetch child ids. +- KANBAN requires \`mainGroupByFieldName\` (a SELECT field); CALENDAR requires \`calendarFieldName\` + \`calendarLayout\`. + +Example: { "objectNameSingular": "opportunity", "type": "KANBAN", "name": "Pipeline", "mainGroupByFieldName": "stage", "kanbanAggregateOperation": "SUM", "kanbanAggregateOperationFieldName": "amount", "fields": [{ "fieldName": "name" }, { "fieldName": "amount" }, { "fieldName": "stage" }], "sorts": [{ "fieldName": "amount", "direction": "DESC" }] } + ## Workflow 1. **Identify the target object**: If the user didn't specify which object, ask them. Present available objects and explain what each holds: @@ -1293,14 +1330,12 @@ You help users create and configure views to organize how they see their records - KANBAN: Ideal when objects have a SELECT field representing stages/statuses (e.g., Opportunity → stage, Task → status) - CALENDAR: Ideal when objects have DATE/DATE_TIME fields (e.g., Opportunity → closeDate, Task → dueAt) -3. **Create the view**: Use create_view with the right parameters. - - For KANBAN: The mainGroupByFieldName is required — ask user which SELECT field to group by, or suggest the most natural one. - - For CALENDAR: You must provide both \`calendarFieldName\` (a DATE/DATE_TIME field name) and \`calendarLayout\` ("DAY", "WEEK", or "MONTH") when calling create_view. - - For TABLE: No special configuration needed. +3. **Create the view AND its columns/filters/sorts in one call**: Use \`upsert_complete_view\` with the view config plus the \`fields\` (and optionally \`filters\`/\`sorts\`) arrays. Reference fields by name. + - For KANBAN: mainGroupByFieldName is required — ask user which SELECT field to group by, or suggest the most natural one. + - For CALENDAR: provide both \`calendarFieldName\` (a DATE/DATE_TIME field name) and \`calendarLayout\` ("DAY", "WEEK", or "MONTH"). + - For TABLE: No special configuration needed beyond the fields list. -4. **Configure view fields**: Use create_many_view_fields to add relevant columns. Choose fields that make sense for the view's purpose. Use decimal positions between 0 and 1 to place them after the label identifier field. - -5. **Navigate**: Use navigate_app to show the user their new view. +4. **Navigate**: Use navigate_app to show the user their new view. ## KANBAN Best Practices @@ -1349,8 +1384,9 @@ You help users add filters and sorts to their views so they see the most relevan - get_views - List existing views to find the one to modify - get_view_query_parameters - Check existing filters and sorts on a view - get_field_metadata - Discover fields and their types to build valid filters -- create_view_filter / create_many_view_filters - Add filters to a view -- create_view_sort / create_many_view_sorts - Add sorts to a view +- **upsert_complete_view** - Replace ALL of a view's filters and/or sorts in one call (pass \`id\` + the desired \`filters\`/\`sorts\` arrays, referencing fields by name). Prefer this when setting the full filter/sort set at once. +- create_view_filter / create_many_view_filters - Add individual filters to a view (use for surgical single-filter edits) +- create_view_sort / create_many_view_sorts - Add individual sorts to a view (use for surgical single-sort edits) - navigate_app - Navigate to the view to show results ## Filter Operators by Field Type diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service.ts index 9c209f3187..824aa3e349 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service.ts @@ -14,7 +14,11 @@ import { } from 'twenty-shared/utils'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { getInvalidSelectFilterOptionValues } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util'; import { ViewFilterExceptionCode } from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception'; +import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type'; +import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type'; @@ -110,6 +114,27 @@ export class FlatViewFilterValidatorService { if (isDefined(incompatibleOperandError)) { validationResult.errors.push(incompatibleOperandError); } + + if ( + isFieldMetadataEntityOfType( + referencedFieldMetadata, + FieldMetadataType.SELECT, + ) || + isFieldMetadataEntityOfType( + referencedFieldMetadata, + FieldMetadataType.MULTI_SELECT, + ) + ) { + const invalidSelectOptionError = this.getInvalidSelectOptionError({ + referencedFieldMetadata, + operand: flatViewFilterToValidate.operand, + value: flatViewFilterToValidate.value, + }); + + if (isDefined(invalidSelectOptionError)) { + validationResult.errors.push(invalidSelectOptionError); + } + } } if ( @@ -247,6 +272,30 @@ export class FlatViewFilterValidatorService { if (isDefined(incompatibleOperandError)) { validationResult.errors.push(incompatibleOperandError); } + + if ( + ('value' in flatEntityUpdate || + 'fieldMetadataUniversalIdentifier' in flatEntityUpdate || + 'operand' in flatEntityUpdate) && + (isFieldMetadataEntityOfType( + referencedFieldMetadata, + FieldMetadataType.SELECT, + ) || + isFieldMetadataEntityOfType( + referencedFieldMetadata, + FieldMetadataType.MULTI_SELECT, + )) + ) { + const invalidSelectOptionError = this.getInvalidSelectOptionError({ + referencedFieldMetadata, + operand: updatedFlatViewFilter.operand, + value: updatedFlatViewFilter.value, + }); + + if (isDefined(invalidSelectOptionError)) { + validationResult.errors.push(invalidSelectOptionError); + } + } } if (isDefined(updatedFlatViewFilter.viewFilterGroupUniversalIdentifier)) { @@ -268,6 +317,42 @@ export class FlatViewFilterValidatorService { return validationResult; } + private getInvalidSelectOptionError({ + referencedFieldMetadata, + operand, + value, + }: { + referencedFieldMetadata: Pick< + FlatFieldMetadata< + FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT + >, + 'type' | 'options' | 'label' + >; + operand: ViewFilterOperand; + value: ViewFilterValue; + }) { + const invalidValues = getInvalidSelectFilterOptionValues({ + fieldMetadata: referencedFieldMetadata, + operand, + value, + }); + + if (invalidValues.length === 0) { + return undefined; + } + + const invalidValuesText = invalidValues.join(', '); + const allowedValuesText = referencedFieldMetadata.options + ?.map((option) => option.value) + .join(', '); + + return { + code: ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA, + message: t`Filter on "${referencedFieldMetadata.label}" uses option(s) ${invalidValuesText} that do not exist. Allowed values: ${allowedValuesText}.`, + userFriendlyMessage: msg`Filter uses a select option that does not exist`, + }; + } + private getIncompatibleOperandError({ operand, fieldType, diff --git a/packages/twenty-server/src/modules/dashboard/tools/__tests__/update-dashboard-widget.tool.spec.ts b/packages/twenty-server/src/modules/dashboard/tools/__tests__/update-dashboard-widget.tool.spec.ts new file mode 100644 index 0000000000..e43f0f8114 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/__tests__/update-dashboard-widget.tool.spec.ts @@ -0,0 +1,177 @@ +import { AggregateOperations, ViewFilterOperand } from 'twenty-shared/types'; + +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 DashboardToolDependencies } from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type'; +import { createUpdateDashboardWidgetTool } from 'src/modules/dashboard/tools/update-dashboard-widget.tool'; + +const WORKSPACE_ID = '20202020-aaaa-4d02-bf25-6aeccf7ea419'; +const WIDGET_ID = '20202020-ffff-4d02-bf25-6aeccf7ea419'; +const OPPORTUNITY_OBJECT_ID = '20202020-dddd-4d02-bf25-6aeccf7ea419'; +const AMOUNT_FIELD_ID = '20202020-bbbb-4d02-bf25-6aeccf7ea419'; +const STAGE_FIELD_ID = '20202020-cccc-4d02-bf25-6aeccf7ea419'; + +const flatObjectMetadataMaps = { + byUniversalIdentifier: { + 'object-opportunity': { + id: OPPORTUNITY_OBJECT_ID, + nameSingular: 'opportunity', + namePlural: 'opportunities', + }, + }, +}; + +const flatFieldMetadataMaps = { + byUniversalIdentifier: { + 'field-amount': { + id: AMOUNT_FIELD_ID, + name: 'amount', + label: 'Amount', + objectMetadataId: OPPORTUNITY_OBJECT_ID, + isActive: true, + type: 'CURRENCY', + }, + 'field-stage': { + id: STAGE_FIELD_ID, + name: 'stage', + label: 'Stage', + objectMetadataId: OPPORTUNITY_OBJECT_ID, + isActive: true, + type: 'SELECT', + }, + }, +}; + +const buildDeps = () => ({ + pageLayoutWidgetService: { + findByIdOrThrow: jest + .fn() + .mockResolvedValue({ objectMetadataId: OPPORTUNITY_OBJECT_ID }), + update: jest.fn().mockImplementation(async ({ updateData }) => ({ + id: WIDGET_ID, + title: 'Widget', + type: WidgetType.GRAPH, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + configuration: updateData.configuration, + })), + }, + flatEntityMapsCacheService: { + getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({ + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }), + }, +}); + +const createTool = (deps: ReturnType) => + createUpdateDashboardWidgetTool( + deps as unknown as Pick< + DashboardToolDependencies, + 'pageLayoutWidgetService' | 'flatEntityMapsCacheService' + >, + { workspaceId: WORKSPACE_ID }, + ); + +describe('update_dashboard_widget tool', () => { + it('resolves filter field names using the existing widget object when the object is not changed', async () => { + const deps = buildDeps(); + const tool = createTool(deps); + + const result = await tool.execute({ + widgetId: WIDGET_ID, + configuration: { + configurationType: WidgetConfigurationType.AGGREGATE_CHART, + aggregateFieldName: 'amount', + aggregateOperation: AggregateOperations.SUM, + displayDataLabel: false, + filter: { + recordFilters: [ + { + fieldName: 'stage', + operand: ViewFilterOperand.IS, + value: '["WON"]', + }, + ], + }, + }, + }); + + expect(result.success).toBe(true); + expect(deps.pageLayoutWidgetService.findByIdOrThrow).toHaveBeenCalledWith({ + id: WIDGET_ID, + workspaceId: WORKSPACE_ID, + }); + + const updateData = deps.pageLayoutWidgetService.update.mock.calls[0][0] + .updateData as { + configuration: { + aggregateFieldMetadataId: string; + filter: { recordFilters: Array> }; + }; + }; + + expect(updateData.configuration.aggregateFieldMetadataId).toBe( + AMOUNT_FIELD_ID, + ); + expect(updateData.configuration.filter.recordFilters[0]).toMatchObject({ + fieldMetadataId: STAGE_FIELD_ID, + operand: ViewFilterOperand.IS, + }); + expect(updateData.configuration.filter.recordFilters[0]).not.toHaveProperty( + 'fieldName', + ); + }); + + it('resolves objectName to a UUID and uses it to resolve field names', async () => { + const deps = buildDeps(); + const tool = createTool(deps); + + const result = await tool.execute({ + widgetId: WIDGET_ID, + objectName: 'opportunities', + configuration: { + configurationType: WidgetConfigurationType.AGGREGATE_CHART, + aggregateFieldName: 'amount', + aggregateOperation: AggregateOperations.SUM, + displayDataLabel: false, + }, + }); + + expect(result.success).toBe(true); + expect(deps.pageLayoutWidgetService.findByIdOrThrow).not.toHaveBeenCalled(); + + const updateData = deps.pageLayoutWidgetService.update.mock.calls[0][0] + .updateData as { + objectMetadataId: string; + objectName?: string; + configuration: { aggregateFieldMetadataId: string }; + }; + + expect(updateData.objectMetadataId).toBe(OPPORTUNITY_OBJECT_ID); + expect(updateData).not.toHaveProperty('objectName'); + expect(updateData.configuration.aggregateFieldMetadataId).toBe( + AMOUNT_FIELD_ID, + ); + }); + + it('does not resolve identifiers when only non-field properties change', async () => { + const deps = buildDeps(); + const tool = createTool(deps); + + const result = await tool.execute({ + widgetId: WIDGET_ID, + title: 'Renamed widget', + }); + + expect(result.success).toBe(true); + expect( + deps.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps, + ).not.toHaveBeenCalled(); + expect(deps.pageLayoutWidgetService.findByIdOrThrow).not.toHaveBeenCalled(); + + const updateData = deps.pageLayoutWidgetService.update.mock.calls[0][0] + .updateData as Record; + + expect(updateData).toEqual({ title: 'Renamed widget' }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts index 3a087785e5..4609b8230f 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts @@ -2,7 +2,6 @@ import { z } from 'zod'; import { type CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/create-page-layout-widget.input'; 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, widgetConfigurationSchema, @@ -12,6 +11,9 @@ import { type DashboardToolContext, type DashboardToolDependencies, } from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type'; +import { type WidgetConfigurationInput } from 'src/modules/dashboard/tools/types/widget-configuration-input.type'; +import { computeDashboardIdentifierMaps } from 'src/modules/dashboard/tools/utils/compute-dashboard-identifier-maps.util'; +import { resolveWidgetFieldNamesToIds } from 'src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util'; const addDashboardWidgetSchema = z.object({ pageLayoutTabId: z.string().uuid().describe('Tab UUID from get_dashboard'), @@ -19,26 +21,36 @@ const addDashboardWidgetSchema = z.object({ type: widgetTypeSchema.describe('Widget type'), gridPosition: gridPositionSchema.describe('Position in 12-column grid'), objectMetadataId: z - .string() .uuid() .optional() .describe( - 'Required for GRAPH and RECORD_TABLE widgets: object UUID to aggregate or display', + 'For GRAPH and RECORD_TABLE widgets: object UUID to aggregate or display. Provide this or objectName.', + ), + objectName: z + .string() + .optional() + .describe( + 'For GRAPH and RECORD_TABLE widgets: object name, singular or plural. Resolved to a UUID — alternative to objectMetadataId.', ), configuration: widgetConfigurationSchema, }); export const createAddDashboardWidgetTool = ( - deps: Pick, + deps: Pick< + DashboardToolDependencies, + 'pageLayoutWidgetService' | 'flatEntityMapsCacheService' + >, 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 get_object_metadata and get_field_metadata to get objectMetadataId and field IDs for GRAPH widgets. +You can reference the object and fields by NAME instead of UUID: pass objectName on the widget and the *FieldName variants in configuration (aggregateFieldName, primaryAxisGroupByFieldName, secondaryAxisGroupByFieldName, groupByFieldName). They are resolved server-side, so get_object_metadata / get_field_metadata are usually unnecessary. UUID variants still work and take precedence. -For RECORD_TABLE widgets: create a dedicated view first with create_view (type TABLE), then pass its viewId in configuration. Never reuse an existing record index view. +Chart widgets (AGGREGATE_CHART, BAR_CHART, LINE_CHART, PIE_CHART) accept configuration.filter to restrict which records feed the chart, e.g. filter: { recordFilters: [{ fieldName: "createdAt", operand: "IS_RELATIVE", value: "PAST_7_DAY" }] }. Filter fields can be referenced by fieldName or fieldMetadataId and must belong to the widget object. + +For RECORD_TABLE widgets: create a dedicated view first with upsert_complete_view (type TABLE, with its fields/filters/sorts in one call), then pass its viewId in configuration. Never reuse an existing record index view. See create_complete_dashboard for full configuration examples.`, inputSchema: addDashboardWidgetSchema, @@ -53,11 +65,24 @@ See create_complete_dashboard for full configuration examples.`, columnSpan: number; }; objectMetadataId?: string; - configuration?: AllPageLayoutWidgetConfiguration; + objectName?: string; + configuration?: WidgetConfigurationInput; }) => { try { + const identifierMaps = await computeDashboardIdentifierMaps( + deps, + context, + ); + const widgetWithMetadataIds = resolveWidgetFieldNamesToIds( + parameters, + identifierMaps, + ); + const widget = await deps.pageLayoutWidgetService.create({ - input: parameters as CreatePageLayoutWidgetInput, + input: { + ...widgetWithMetadataIds, + pageLayoutTabId: parameters.pageLayoutTabId, + } as CreatePageLayoutWidgetInput, workspaceId: context.workspaceId, }); 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 8acf321a07..2e8cc0c626 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 @@ -3,7 +3,6 @@ import { z } from 'zod'; import { type CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/create-page-layout-widget.input'; 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 { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; import { @@ -15,17 +14,25 @@ import { type DashboardToolContext, type DashboardToolDependencies, } from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type'; +import { type WidgetConfigurationInput } from 'src/modules/dashboard/tools/types/widget-configuration-input.type'; +import { computeDashboardIdentifierMaps } from 'src/modules/dashboard/tools/utils/compute-dashboard-identifier-maps.util'; +import { resolveWidgetFieldNamesToIds } from 'src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util'; 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 and RECORD_TABLE widgets: UUID of the object to aggregate or display', + 'For GRAPH and RECORD_TABLE widgets: UUID of the object to aggregate or display. Provide this or objectName.', + ), + objectName: z + .string() + .optional() + .describe( + 'For GRAPH and RECORD_TABLE widgets: object name, singular or plural (e.g. "opportunity"). Resolved to a UUID — alternative to objectMetadataId.', ), configuration: widgetConfigurationSchema, }); @@ -51,7 +58,7 @@ export const createCreateCompleteDashboardTool = ( name: 'create_complete_dashboard' as const, description: `Create a dashboard with layout, tab, and widgets. -IMPORTANT: Before creating GRAPH widgets, you MUST use get_object_metadata and get_field_metadata to get valid objectMetadataId and field IDs. +OBJECT & FIELD REFERENCES: You can reference the object and fields by NAME instead of UUID. Use objectName (e.g. "opportunity") on the widget and the *FieldName variants in configuration (aggregateFieldName, primaryAxisGroupByFieldName, secondaryAxisGroupByFieldName, groupByFieldName). They are resolved to UUIDs server-side, so you usually do NOT need get_object_metadata / get_field_metadata first. UUID variants (objectMetadataId, *FieldMetadataId) still work and take precedence when both are given. GRID SYSTEM: - 12 columns (0-11), rows start at 0 @@ -78,6 +85,14 @@ WIDGET TYPES: - Additional required: configuration.groupByFieldMetadataId (note: different field name!) - Example: { type: "GRAPH", objectMetadataId: "", configuration: { configurationType: "PIE_CHART", aggregateFieldMetadataId: "", aggregateOperation: "COUNT", groupByFieldMetadataId: "" } } +CHART FILTERS (AGGREGATE_CHART, BAR_CHART, LINE_CHART, PIE_CHART): +- Add configuration.filter to restrict which records feed the chart. All filtered fields must belong to the widget object. +- Reference filter fields by name (fieldName) or UUID (fieldMetadataId). +- Shape: { filter: { recordFilters: [{ fieldName, operand, value, subFieldName? }] } }. Multiple rules are ANDed; use recordFilterGroups with logicalOperator AND/OR for advanced logic. +- Relative dates: operand "IS_RELATIVE" with value like "PAST_7_DAY", "THIS_1_MONTH", "NEXT_3_WEEK" (DIRECTION_AMOUNT_UNIT; DIRECTION=PAST|THIS|NEXT, UNIT=DAY|WEEK|MONTH|QUARTER|YEAR). Use IS_IN_PAST/IS_IN_FUTURE/IS_TODAY (no value) for open-ended ranges. +- SELECT/MULTI_SELECT/RELATION values are JSON array strings, e.g. '["WON"]'. CURRENCY value is the major unit with subFieldName "amountMicros". +- Example (won opportunities created in the last 30 days): { type: "GRAPH", objectName: "opportunity", configuration: { configurationType: "AGGREGATE_CHART", aggregateFieldName: "amount", aggregateOperation: "SUM", filter: { recordFilters: [{ fieldName: "stage", operand: "IS", value: "[\\"WON\\"]" }, { fieldName: "createdAt", operand: "IS_RELATIVE", value: "PAST_30_DAY" }] } } } + 5. IFRAME: { type: "IFRAME", configuration: { configurationType: "IFRAME", url: "https://..." } } 6. STANDALONE_RICH_TEXT: { type: "STANDALONE_RICH_TEXT", configuration: { configurationType: "STANDALONE_RICH_TEXT", body: { ... } } } @@ -105,12 +120,17 @@ AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY` columnSpan: number; }; objectMetadataId?: string; - configuration?: AllPageLayoutWidgetConfiguration; + objectName?: string; + configuration?: WidgetConfigurationInput; }>; }) => { try { const tabTitle = parameters.tabTitle ?? 'Main'; const widgets = parameters.widgets ?? []; + const identifierMaps = + widgets.length > 0 + ? await computeDashboardIdentifierMaps(deps, context) + : null; const pageLayout = await deps.pageLayoutService.create({ createPageLayoutInput: { name: parameters.title, @@ -133,9 +153,13 @@ AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY` for (const widget of widgets) { try { + const widgetWithMetadataIds = identifierMaps + ? resolveWidgetFieldNamesToIds(widget, identifierMaps) + : widget; + const createdWidget = await deps.pageLayoutWidgetService.create({ input: { - ...widget, + ...widgetWithMetadataIds, pageLayoutTabId: pageLayoutTab.id, } as CreatePageLayoutWidgetInput, workspaceId: context.workspaceId, 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 1bd581a793..3f07cc12f8 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 @@ -2,6 +2,7 @@ import { isNumber } from '@sniptt/guards'; import { AggregateOperations, ObjectRecordGroupByDateGranularity, + ViewFilterOperand, } from 'twenty-shared/types'; import { z } from 'zod'; @@ -85,6 +86,83 @@ const AGGREGATE_OPERATION_OPTIONS = Object.values(AggregateOperations) as [ ...AggregateOperations[], ]; +const FILTER_OPERAND_OPTIONS = Object.values(ViewFilterOperand) as [ + ViewFilterOperand, + ...ViewFilterOperand[], +]; + +const chartRecordFilterSchema = z.object({ + fieldMetadataId: z + .uuid() + .optional() + .describe( + 'Field UUID to filter on (must belong to the widget object). Provide this or fieldName.', + ), + fieldName: z + .string() + .optional() + .describe( + 'Field name to filter on (resolved to a UUID against the widget object). Alternative to fieldMetadataId.', + ), + operand: z + .enum(FILTER_OPERAND_OPTIONS) + .describe( + 'Filter operator. Valid operators per field type — TEXT/EMAILS/FULL_NAME/ARRAY/PHONES: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY. NUMBER/CURRENCY/RATING: GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY. DATE/DATE_TIME: IS, IS_RELATIVE, IS_IN_PAST, IS_IN_FUTURE, IS_TODAY, IS_BEFORE, IS_AFTER, IS_EMPTY, IS_NOT_EMPTY. SELECT: IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY. MULTI_SELECT: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY. RELATION: IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY. BOOLEAN: IS.', + ), + value: z + .string() + .optional() + .describe( + 'Filter value as a string. TEXT: plain string. NUMBER/CURRENCY: numeric string (CURRENCY value is the major unit, e.g. "1000"). BOOLEAN: "true" or "false". SELECT/MULTI_SELECT/RELATION: JSON array string of option values or record UUIDs, e.g. \'["OPTION_1","OPTION_2"]\'. Relative dates (operand IS_RELATIVE): "DIRECTION_AMOUNT_UNIT" where DIRECTION is PAST|THIS|NEXT and UNIT is DAY|WEEK|MONTH|QUARTER|YEAR — e.g. "PAST_7_DAY", "THIS_1_MONTH", "NEXT_3_WEEK" (use THIS_1_ for the current period). Absolute dates (IS/IS_BEFORE/IS_AFTER): ISO date string. Omit for IS_EMPTY/IS_NOT_EMPTY/IS_TODAY/IS_IN_PAST/IS_IN_FUTURE.', + ), + subFieldName: z + .string() + .optional() + .describe( + 'Required for composite fields — e.g. "amountMicros" or "currencyCode" for CURRENCY, "addressCity" for ADDRESS, "firstName"/"lastName" for FULL_NAME.', + ), + recordFilterGroupId: z + .string() + .optional() + .describe( + 'ID of the record filter group this rule belongs to (for AND/OR grouping). Must match an id in recordFilterGroups. Omit when recordFilterGroups is not used.', + ), +}); + +const chartRecordFilterGroupSchema = z.object({ + id: z + .string() + .describe( + 'Unique id for this filter group, referenced by recordFilterGroupId on filter rules.', + ), + logicalOperator: z + .enum(['AND', 'OR']) + .describe('How rules within this group are combined.'), + parentRecordFilterGroupId: z + .string() + .optional() + .describe('Parent group id, for nested grouping.'), +}); + +export const chartFilterSchema = z + .object({ + recordFilters: z + .array(chartRecordFilterSchema) + .optional() + .describe( + 'Filter rules applied to the records feeding this chart. Multiple rules with no recordFilterGroups are combined with AND.', + ), + recordFilterGroups: z + .array(chartRecordFilterGroupSchema) + .optional() + .describe( + 'Optional groups to combine filter rules with AND/OR logic. Omit for a simple list of ANDed rules.', + ), + }) + .describe( + 'Filter restricting which records are included in this chart. All filtered fields must belong to the widget object.', + ); + const displayDataLabelSchema = z.boolean().optional(); const displayLegendSchema = z.boolean().optional(); const showCenterMetricSchema = z @@ -114,7 +192,16 @@ type RangeMinMaxFields = { }; const ratioAggregateConfigSchema = z.object({ - fieldMetadataId: z.uuid(), + fieldMetadataId: z + .uuid() + .optional() + .describe('Field UUID. Provide this or fieldName.'), + fieldName: z + .string() + .optional() + .describe( + 'Field name (resolved to a UUID). Alternative to fieldMetadataId.', + ), optionValue: z.string(), }); @@ -205,8 +292,15 @@ const aggregateChartConfigSchemaBase = z.object({ configurationType: z.literal(WidgetConfigurationType.AGGREGATE_CHART), aggregateFieldMetadataId: z .uuid() + .optional() .describe( - 'Field UUID to aggregate (must be from the widget objectMetadataId)', + 'Field UUID to aggregate (must be from the widget object). Provide this or aggregateFieldName.', + ), + aggregateFieldName: z + .string() + .optional() + .describe( + 'Field name to aggregate (resolved to a UUID against the widget object). Alternative to aggregateFieldMetadataId.', ), aggregateOperation: z .enum(AGGREGATE_OPERATION_OPTIONS) @@ -222,6 +316,7 @@ const aggregateChartConfigSchemaBase = z.object({ prefix: z.string().optional(), suffix: z.string().optional(), ratioAggregateConfig: ratioAggregateConfigSchema.optional(), + filter: chartFilterSchema.optional(), }); const aggregateChartConfigSchema = aggregateChartConfigSchemaBase.extend({ @@ -234,11 +329,29 @@ const aggregateChartConfigSchemaWithoutDefaults = // Graph configuration schema for BAR charts const barChartConfigSchemaCore = z.object({ configurationType: z.literal(WidgetConfigurationType.BAR_CHART), - aggregateFieldMetadataId: z.uuid().describe('Field UUID to aggregate'), + aggregateFieldMetadataId: z + .uuid() + .optional() + .describe('Field UUID to aggregate. Provide this or aggregateFieldName.'), + aggregateFieldName: z + .string() + .optional() + .describe( + 'Field name to aggregate (resolved to a UUID). Alternative to aggregateFieldMetadataId.', + ), aggregateOperation: z.enum(AGGREGATE_OPERATION_OPTIONS), primaryAxisGroupByFieldMetadataId: z .uuid() - .describe('Field UUID to group by on primary axis'), + .optional() + .describe( + 'Field UUID to group by on primary axis. Provide this or primaryAxisGroupByFieldName.', + ), + primaryAxisGroupByFieldName: z + .string() + .optional() + .describe( + 'Field name to group by on primary axis (resolved to a UUID). Alternative to primaryAxisGroupByFieldMetadataId.', + ), primaryAxisGroupBySubFieldName: z .string() .optional() @@ -246,6 +359,12 @@ const barChartConfigSchemaCore = z.object({ '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(), + secondaryAxisGroupByFieldName: z + .string() + .optional() + .describe( + 'Field name to group by on secondary axis (resolved to a UUID). Alternative to secondaryAxisGroupByFieldMetadataId.', + ), secondaryAxisGroupBySubFieldName: z .string() .optional() @@ -282,6 +401,7 @@ const barChartConfigSchemaCore = z.object({ layout: z .enum(BAR_CHART_LAYOUT_OPTIONS) .describe('Layout orientation for bar charts'), + filter: chartFilterSchema.optional(), }); const barChartConfigSchemaWithoutDefaults = withRangeMinMaxRefinement( @@ -300,9 +420,29 @@ const barChartConfigSchema = withRangeMinMaxRefinement( // Graph configuration schema for LINE charts const lineChartConfigSchemaCore = z.object({ configurationType: z.literal(WidgetConfigurationType.LINE_CHART), - aggregateFieldMetadataId: z.uuid(), + aggregateFieldMetadataId: z + .uuid() + .optional() + .describe('Field UUID to aggregate. Provide this or aggregateFieldName.'), + aggregateFieldName: z + .string() + .optional() + .describe( + 'Field name to aggregate (resolved to a UUID). Alternative to aggregateFieldMetadataId.', + ), aggregateOperation: z.enum(AGGREGATE_OPERATION_OPTIONS), - primaryAxisGroupByFieldMetadataId: z.uuid(), + primaryAxisGroupByFieldMetadataId: z + .uuid() + .optional() + .describe( + 'Field UUID to group by on primary axis. Provide this or primaryAxisGroupByFieldName.', + ), + primaryAxisGroupByFieldName: z + .string() + .optional() + .describe( + 'Field name to group by on primary axis (resolved to a UUID). Alternative to primaryAxisGroupByFieldMetadataId.', + ), primaryAxisGroupBySubFieldName: z .string() .optional() @@ -310,6 +450,12 @@ const lineChartConfigSchemaCore = z.object({ '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(), + secondaryAxisGroupByFieldName: z + .string() + .optional() + .describe( + 'Field name to group by on secondary axis (resolved to a UUID). Alternative to secondaryAxisGroupByFieldMetadataId.', + ), secondaryAxisGroupBySubFieldName: z .string() .optional() @@ -340,6 +486,7 @@ const lineChartConfigSchemaCore = z.object({ isCumulative: z.boolean().optional().describe('Show running totals'), rangeMin: z.number().optional().describe('Y axis minimum value'), rangeMax: z.number().optional().describe('Y axis maximum value'), + filter: chartFilterSchema.optional(), }); const lineChartConfigSchemaWithoutDefaults = withRangeMinMaxRefinement( @@ -358,9 +505,27 @@ const lineChartConfigSchema = withRangeMinMaxRefinement( // Graph configuration schema for PIE charts const pieChartConfigSchemaCore = z.object({ configurationType: z.literal(WidgetConfigurationType.PIE_CHART), - aggregateFieldMetadataId: z.uuid(), + aggregateFieldMetadataId: z + .uuid() + .optional() + .describe('Field UUID to aggregate. Provide this or aggregateFieldName.'), + aggregateFieldName: z + .string() + .optional() + .describe( + 'Field name to aggregate (resolved to a UUID). Alternative to aggregateFieldMetadataId.', + ), aggregateOperation: z.enum(AGGREGATE_OPERATION_OPTIONS), - groupByFieldMetadataId: z.uuid().describe('Field UUID to slice by'), + groupByFieldMetadataId: z + .uuid() + .optional() + .describe('Field UUID to slice by. Provide this or groupByFieldName.'), + groupByFieldName: z + .string() + .optional() + .describe( + 'Field name to slice by (resolved to a UUID). Alternative to groupByFieldMetadataId.', + ), groupBySubFieldName: z .string() .optional() @@ -378,6 +543,7 @@ const pieChartConfigSchemaCore = z.object({ displayLegend: displayLegendSchema, showCenterMetric: showCenterMetricSchema, hideEmptyCategory: hideEmptyCategorySchema, + filter: chartFilterSchema.optional(), }); const pieChartConfigSchemaWithoutDefaults = withManualSortRefinement( diff --git a/packages/twenty-server/src/modules/dashboard/tools/types/chart-filter-input.type.ts b/packages/twenty-server/src/modules/dashboard/tools/types/chart-filter-input.type.ts new file mode 100644 index 0000000000..4c25556daf --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/types/chart-filter-input.type.ts @@ -0,0 +1,5 @@ +import { type z } from 'zod'; + +import { type chartFilterSchema } from 'src/modules/dashboard/tools/schemas/widget.schema'; + +export type ChartFilterInput = z.infer; diff --git a/packages/twenty-server/src/modules/dashboard/tools/types/dashboard-identifier-maps.type.ts b/packages/twenty-server/src/modules/dashboard/tools/types/dashboard-identifier-maps.type.ts new file mode 100644 index 0000000000..89840da278 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/types/dashboard-identifier-maps.type.ts @@ -0,0 +1,7 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +export type DashboardIdentifierMaps = { + objectIdByName: Record; + fieldIdByObjectIdAndName: Map; + fieldById: Map; +}; diff --git a/packages/twenty-server/src/modules/dashboard/tools/types/widget-configuration-input.type.ts b/packages/twenty-server/src/modules/dashboard/tools/types/widget-configuration-input.type.ts new file mode 100644 index 0000000000..ac95c4c6a7 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/types/widget-configuration-input.type.ts @@ -0,0 +1,7 @@ +import { type z } from 'zod'; + +import { type widgetConfigurationSchema } from 'src/modules/dashboard/tools/schemas/widget.schema'; + +export type WidgetConfigurationInput = NonNullable< + z.infer +>; diff --git a/packages/twenty-server/src/modules/dashboard/tools/types/widget-identifiers-input.type.ts b/packages/twenty-server/src/modules/dashboard/tools/types/widget-identifiers-input.type.ts new file mode 100644 index 0000000000..5b7bbc02ff --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/types/widget-identifiers-input.type.ts @@ -0,0 +1,14 @@ +import { type z } from 'zod'; + +import { type WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum'; +import { type gridPositionSchema } from 'src/modules/dashboard/tools/schemas/widget.schema'; +import { type WidgetConfigurationInput } from 'src/modules/dashboard/tools/types/widget-configuration-input.type'; + +export type WidgetIdentifiersInput = { + title: string; + type: WidgetType; + gridPosition: z.infer; + objectMetadataId?: string; + objectName?: string; + configuration?: WidgetConfigurationInput; +}; diff --git a/packages/twenty-server/src/modules/dashboard/tools/types/widget-with-metadata-ids.type.ts b/packages/twenty-server/src/modules/dashboard/tools/types/widget-with-metadata-ids.type.ts new file mode 100644 index 0000000000..eb51c2a817 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/types/widget-with-metadata-ids.type.ts @@ -0,0 +1,13 @@ +import { type 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 { type gridPositionSchema } from 'src/modules/dashboard/tools/schemas/widget.schema'; + +export type WidgetWithMetadataIds = { + title: string; + type: WidgetType; + gridPosition: z.infer; + objectMetadataId?: string; + configuration?: AllPageLayoutWidgetConfiguration; +}; 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 bfef2bcb91..4ccacc4fdc 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 @@ -1,3 +1,4 @@ +import { isNonEmptyString } from '@sniptt/guards'; import { isDefined, isEmptyObject } from 'twenty-shared/utils'; import { z } from 'zod'; @@ -12,6 +13,12 @@ import { type DashboardToolContext, type DashboardToolDependencies, } from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type'; +import { type WidgetConfigurationInput } from 'src/modules/dashboard/tools/types/widget-configuration-input.type'; +import { computeDashboardIdentifierMaps } from 'src/modules/dashboard/tools/utils/compute-dashboard-identifier-maps.util'; +import { + getObjectMetadataId, + resolveConfigurationFieldNamesToIds, +} from 'src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util'; const updateDashboardWidgetSchema = z.object({ widgetId: z.string().uuid().describe('The UUID of the widget to update'), @@ -21,15 +28,23 @@ const updateDashboardWidgetSchema = z.object({ .optional() .describe('New position and size in the grid layout'), objectMetadataId: z - .string() .uuid() .optional() - .describe('New object metadata ID'), + .describe('New object metadata ID. Provide this or objectName.'), + objectName: z + .string() + .optional() + .describe( + 'New object name, singular or plural. Resolved to a UUID — alternative to objectMetadataId.', + ), configuration: widgetConfigurationSchemaWithoutDefaults.optional(), }); export const createUpdateDashboardWidgetTool = ( - deps: Pick, + deps: Pick< + DashboardToolDependencies, + 'pageLayoutWidgetService' | 'flatEntityMapsCacheService' + >, context: DashboardToolContext, ) => ({ name: 'update_dashboard_widget' as const, @@ -37,6 +52,8 @@ export const createUpdateDashboardWidgetTool = ( Use get_dashboard first to find the widgetId. +You can reference the object and fields by NAME instead of UUID: pass objectName and the *FieldName variants in configuration (aggregateFieldName, primaryAxisGroupByFieldName, secondaryAxisGroupByFieldName, groupByFieldName) and fieldName inside filter recordFilters. They are resolved server-side against the widget object, falling back to the widget's existing object when you don't change it. UUID variants still work and take precedence. + Only provide fields you want to change - others remain unchanged.`, inputSchema: updateDashboardWidgetSchema, execute: async (parameters: { @@ -50,12 +67,56 @@ Only provide fields you want to change - others remain unchanged.`, columnSpan: number; }; objectMetadataId?: string; - configuration?: AllPageLayoutWidgetConfiguration; + objectName?: string; + configuration?: WidgetConfigurationInput; }) => { try { - const { widgetId, ...updates } = parameters; + const { widgetId, objectName, configuration, ...rest } = parameters; + + const hasConfigurationUpdate = + isDefined(configuration) && !isEmptyObject(configuration); + const shouldResolveIdentifiers = + hasConfigurationUpdate || isNonEmptyString(objectName); + + let resolvedObjectMetadataId = rest.objectMetadataId; + let resolvedConfiguration: AllPageLayoutWidgetConfiguration | undefined; + + if (shouldResolveIdentifiers) { + const identifierMaps = await computeDashboardIdentifierMaps( + deps, + context, + ); + + resolvedObjectMetadataId = getObjectMetadataId({ + objectMetadataId: rest.objectMetadataId, + objectName, + maps: identifierMaps, + }); + + if (isDefined(configuration) && !isEmptyObject(configuration)) { + const objectMetadataIdForFields = + resolvedObjectMetadataId ?? + ( + await deps.pageLayoutWidgetService.findByIdOrThrow({ + id: widgetId, + workspaceId: context.workspaceId, + }) + ).objectMetadataId; + + resolvedConfiguration = resolveConfigurationFieldNamesToIds( + configuration, + objectMetadataIdForFields, + identifierMaps, + ); + } + } + const updateData = Object.fromEntries( - Object.entries(updates).filter(([key, value]) => { + Object.entries({ + ...rest, + objectMetadataId: resolvedObjectMetadataId, + configuration: resolvedConfiguration, + }).filter(([key, value]) => { if (!isDefined(value)) { return false; } diff --git a/packages/twenty-server/src/modules/dashboard/tools/utils/__tests__/resolve-widget-field-names-to-metadata-ids.util.spec.ts b/packages/twenty-server/src/modules/dashboard/tools/utils/__tests__/resolve-widget-field-names-to-metadata-ids.util.spec.ts new file mode 100644 index 0000000000..883c72e7c5 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/utils/__tests__/resolve-widget-field-names-to-metadata-ids.util.spec.ts @@ -0,0 +1,215 @@ +import { + AggregateOperations, + FieldMetadataType, + ViewFilterOperand, +} from 'twenty-shared/types'; + +import { buildFieldByObjectIdAndNameKey } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util'; +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 DashboardIdentifierMaps } from 'src/modules/dashboard/tools/types/dashboard-identifier-maps.type'; +import { type WidgetIdentifiersInput } from 'src/modules/dashboard/tools/types/widget-identifiers-input.type'; +import { resolveWidgetFieldNamesToIds } from 'src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util'; + +const OBJECT_ID = '11111111-1111-4111-8111-111111111111'; +const AMOUNT_FIELD_ID = '22222222-2222-4222-8222-222222222222'; +const STAGE_FIELD_ID = '33333333-3333-4333-8333-333333333333'; +const CREATED_AT_FIELD_ID = '44444444-4444-4444-8444-444444444444'; + +const buildMaps = (): DashboardIdentifierMaps => ({ + objectIdByName: { opportunity: OBJECT_ID, opportunities: OBJECT_ID }, + fieldIdByObjectIdAndName: new Map([ + [buildFieldByObjectIdAndNameKey(OBJECT_ID, 'amount'), AMOUNT_FIELD_ID], + [buildFieldByObjectIdAndNameKey(OBJECT_ID, 'stage'), STAGE_FIELD_ID], + [ + buildFieldByObjectIdAndNameKey(OBJECT_ID, 'createdAt'), + CREATED_AT_FIELD_ID, + ], + ]), + fieldById: new Map([ + [AMOUNT_FIELD_ID, { type: FieldMetadataType.CURRENCY }], + [STAGE_FIELD_ID, { type: FieldMetadataType.SELECT }], + [CREATED_AT_FIELD_ID, { type: FieldMetadataType.DATE_TIME }], + ]), +}); + +const getResolvedFilter = (configuration: unknown) => + ( + configuration as { + filter: { + recordFilters: Array>; + recordFilterGroups: Array>; + }; + } + ).filter; + +const buildAggregateWidget = ( + filter: NonNullable< + Extract< + WidgetIdentifiersInput['configuration'], + { configurationType: WidgetConfigurationType.AGGREGATE_CHART } + > + >['filter'], +): WidgetIdentifiersInput => ({ + title: 'KPI', + type: WidgetType.GRAPH, + gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 4 }, + objectName: 'opportunity', + configuration: { + configurationType: WidgetConfigurationType.AGGREGATE_CHART, + aggregateFieldName: 'amount', + aggregateOperation: AggregateOperations.SUM, + displayDataLabel: true, + filter, + }, +}); + +describe('resolveWidgetFieldNamesToIds - chart filters', () => { + it('resolves filter fieldName to fieldMetadataId', () => { + const widget = buildAggregateWidget({ + recordFilters: [ + { + fieldName: 'stage', + operand: ViewFilterOperand.IS, + value: '["WON"]', + }, + ], + }); + + const result = resolveWidgetFieldNamesToIds(widget, buildMaps()); + + expect(result.configuration).toMatchObject({ + aggregateFieldMetadataId: AMOUNT_FIELD_ID, + filter: { + recordFilters: [ + { + fieldMetadataId: STAGE_FIELD_ID, + operand: ViewFilterOperand.IS, + value: '["WON"]', + }, + ], + }, + }); + expect( + (result.configuration as { filter: { recordFilters: unknown[] } }).filter + .recordFilters[0], + ).not.toHaveProperty('fieldName'); + }); + + it('enriches the filter with a root group, type and displayValue for the UI', () => { + const widget = buildAggregateWidget({ + recordFilters: [ + { + fieldName: 'stage', + operand: ViewFilterOperand.IS_NOT, + value: '["NEW"]', + }, + ], + }); + + const result = resolveWidgetFieldNamesToIds(widget, buildMaps()); + const filter = getResolvedFilter(result.configuration); + + expect(filter.recordFilterGroups).toHaveLength(1); + + const rootGroup = filter.recordFilterGroups[0]; + + expect(rootGroup).toMatchObject({ logicalOperator: 'AND' }); + expect(rootGroup.parentRecordFilterGroupId).toBeUndefined(); + + expect(filter.recordFilters[0]).toMatchObject({ + fieldMetadataId: STAGE_FIELD_ID, + operand: ViewFilterOperand.IS_NOT, + value: '["NEW"]', + displayValue: '["NEW"]', + type: 'SELECT', + recordFilterGroupId: rootGroup.id, + positionInRecordFilterGroup: 0, + }); + expect(filter.recordFilters[0].id).toEqual(expect.any(String)); + }); + + it('preserves an explicit filter fieldMetadataId', () => { + const widget = buildAggregateWidget({ + recordFilters: [ + { + fieldMetadataId: STAGE_FIELD_ID, + operand: ViewFilterOperand.IS_NOT, + value: '["LOST"]', + }, + ], + }); + + const result = resolveWidgetFieldNamesToIds(widget, buildMaps()); + + expect(result.configuration).toMatchObject({ + filter: { + recordFilters: [{ fieldMetadataId: STAGE_FIELD_ID }], + }, + }); + }); + + it('passes relative date filter values through unchanged', () => { + const widget = buildAggregateWidget({ + recordFilters: [ + { + fieldName: 'createdAt', + operand: ViewFilterOperand.IS_RELATIVE, + value: 'PAST_7_DAY', + }, + ], + }); + + const result = resolveWidgetFieldNamesToIds(widget, buildMaps()); + + expect(result.configuration).toMatchObject({ + filter: { + recordFilters: [ + { + fieldMetadataId: CREATED_AT_FIELD_ID, + operand: ViewFilterOperand.IS_RELATIVE, + value: 'PAST_7_DAY', + }, + ], + }, + }); + }); + + it('keeps recordFilterGroups for AND/OR logic', () => { + const widget = buildAggregateWidget({ + recordFilters: [ + { + fieldName: 'stage', + operand: ViewFilterOperand.IS, + value: '["WON"]', + recordFilterGroupId: 'group-1', + }, + ], + recordFilterGroups: [{ id: 'group-1', logicalOperator: 'OR' }], + }); + + const result = resolveWidgetFieldNamesToIds(widget, buildMaps()); + + expect(result.configuration).toMatchObject({ + filter: { + recordFilterGroups: [{ id: 'group-1', logicalOperator: 'OR' }], + }, + }); + }); + + it('throws a helpful error when a filter field name is unknown', () => { + const widget = buildAggregateWidget({ + recordFilters: [ + { + fieldName: 'nonExistentField', + operand: ViewFilterOperand.IS, + value: '["X"]', + }, + ], + }); + + expect(() => resolveWidgetFieldNamesToIds(widget, buildMaps())).toThrow( + /Field "nonExistentField" not found/, + ); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/tools/utils/compute-dashboard-identifier-maps.util.ts b/packages/twenty-server/src/modules/dashboard/tools/utils/compute-dashboard-identifier-maps.util.ts new file mode 100644 index 0000000000..baafba2116 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/utils/compute-dashboard-identifier-maps.util.ts @@ -0,0 +1,34 @@ +import { buildFieldIdByNameMaps } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-id-by-name-maps.util'; +import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; +import { type DashboardIdentifierMaps } from 'src/modules/dashboard/tools/types/dashboard-identifier-maps.type'; +import { + type DashboardToolContext, + type DashboardToolDependencies, +} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type'; + +export const computeDashboardIdentifierMaps = async ( + deps: Pick, + context: DashboardToolContext, +): Promise => { + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await deps.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId: context.workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + const { idByNameSingular, idByNamePlural } = buildObjectIdByNameMaps( + flatObjectMetadataMaps, + ); + + const { fieldIdByObjectIdAndName, fieldById } = buildFieldIdByNameMaps( + flatFieldMetadataMaps, + ); + + return { + objectIdByName: { ...idByNamePlural, ...idByNameSingular }, + fieldIdByObjectIdAndName, + fieldById, + }; +}; diff --git a/packages/twenty-server/src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util.ts b/packages/twenty-server/src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util.ts new file mode 100644 index 0000000000..c1ee159870 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util.ts @@ -0,0 +1,364 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { + type ChartFilter, + type ChartRecordFilter, + type ChartRecordFilterGroup, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; + +import { buildFieldByObjectIdAndNameKey } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util'; +import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; +import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type'; +import { type ChartFilterInput } from 'src/modules/dashboard/tools/types/chart-filter-input.type'; +import { type DashboardIdentifierMaps } from 'src/modules/dashboard/tools/types/dashboard-identifier-maps.type'; +import { type WidgetConfigurationInput } from 'src/modules/dashboard/tools/types/widget-configuration-input.type'; +import { type WidgetIdentifiersInput } from 'src/modules/dashboard/tools/types/widget-identifiers-input.type'; +import { type WidgetWithMetadataIds } from 'src/modules/dashboard/tools/types/widget-with-metadata-ids.type'; + +const getFieldMetadataIdOrThrow = ( + { + fieldMetadataId, + fieldName, + objectMetadataId, + maps, + }: { + fieldMetadataId: string | undefined; + fieldName: string | undefined; + objectMetadataId: string | undefined; + maps: DashboardIdentifierMaps; + }, + referenceLabel: string, +): string => { + if (isDefined(fieldMetadataId)) { + return fieldMetadataId; + } + + if (!isNonEmptyString(fieldName)) { + throw new Error( + `Missing required ${referenceLabel}: provide either its UUID or its field name.`, + ); + } + + if (!isDefined(objectMetadataId)) { + throw new Error( + `Cannot look up field "${fieldName}": the widget has no object. Provide objectName or objectMetadataId on the widget.`, + ); + } + + const fieldMetadataIdForName = maps.fieldIdByObjectIdAndName.get( + buildFieldByObjectIdAndNameKey(objectMetadataId, fieldName), + ); + + if (!isDefined(fieldMetadataIdForName)) { + throw new Error( + `Field "${fieldName}" not found on this object. Use get_object_metadata with includeFields (or get_field_metadata) to list available field names.`, + ); + } + + return fieldMetadataIdForName; +}; + +export const getObjectMetadataId = ({ + objectMetadataId, + objectName, + maps, +}: { + objectMetadataId: string | undefined; + objectName: string | undefined; + maps: DashboardIdentifierMaps; +}): string | undefined => { + if (isDefined(objectMetadataId)) { + return objectMetadataId; + } + + if (!isNonEmptyString(objectName)) { + return undefined; + } + + const objectMetadataIdForName = maps.objectIdByName[objectName]; + + if (!isDefined(objectMetadataIdForName)) { + throw new Error( + `Object "${objectName}" not found. Use get_object_metadata to list available objects.`, + ); + } + + return objectMetadataIdForName; +}; + +type ResolvedChartRecordFilter = ChartRecordFilter & { + id: string; + displayValue: string; + positionInRecordFilterGroup: number; +}; + +const resolveChartFilterFieldNamesToIds = ( + filter: ChartFilterInput | undefined, + objectMetadataId: string | undefined, + maps: DashboardIdentifierMaps, +): ChartFilter | undefined => { + if (!isDefined(filter)) { + return undefined; + } + + const inputRecordFilters = filter.recordFilters ?? []; + + if (inputRecordFilters.length === 0) { + return undefined; + } + + const inputRecordFilterGroups = filter.recordFilterGroups ?? []; + + const existingRootGroup = inputRecordFilterGroups.find( + (group) => !isDefined(group.parentRecordFilterGroupId), + ); + + const rootGroup: ChartRecordFilterGroup = existingRootGroup ?? { + id: uuidv4(), + logicalOperator: 'AND', + }; + + const recordFilterGroups: ChartRecordFilterGroup[] = isDefined( + existingRootGroup, + ) + ? inputRecordFilterGroups + : [rootGroup, ...inputRecordFilterGroups]; + + const validGroupIds = new Set(recordFilterGroups.map((group) => group.id)); + + const positionByGroupId = new Map(); + + const recordFilters: ResolvedChartRecordFilter[] = inputRecordFilters.map( + (recordFilter) => { + const { fieldName, fieldMetadataId, ...rest } = recordFilter; + + const resolvedFieldMetadataId = getFieldMetadataIdOrThrow( + { + fieldMetadataId, + fieldName, + objectMetadataId, + maps, + }, + 'filter field', + ); + + const recordFilterGroupId = rest.recordFilterGroupId ?? rootGroup.id; + + if (!validGroupIds.has(recordFilterGroupId)) { + throw new Error( + `Invalid recordFilterGroupId "${recordFilterGroupId}": no matching filter group exists. Provide a valid group id or omit to use the root group.`, + ); + } + + const positionInRecordFilterGroup = + positionByGroupId.get(recordFilterGroupId) ?? 0; + + positionByGroupId.set( + recordFilterGroupId, + positionInRecordFilterGroup + 1, + ); + + const field = maps.fieldById.get(resolvedFieldMetadataId); + const value = rest.value ?? ''; + + return { + id: uuidv4(), + fieldMetadataId: resolvedFieldMetadataId, + operand: rest.operand, + value, + displayValue: value, + type: field?.type ?? '', + recordFilterGroupId, + positionInRecordFilterGroup, + ...(isDefined(rest.subFieldName) + ? { subFieldName: rest.subFieldName } + : {}), + }; + }, + ); + + return { + recordFilters, + recordFilterGroups, + }; +}; + +export const resolveConfigurationFieldNamesToIds = ( + configuration: WidgetConfigurationInput, + objectMetadataId: string | undefined, + maps: DashboardIdentifierMaps, +): AllPageLayoutWidgetConfiguration => { + switch (configuration.configurationType) { + case WidgetConfigurationType.AGGREGATE_CHART: { + const { + aggregateFieldName, + aggregateFieldMetadataId, + ratioAggregateConfig, + filter, + ...rest + } = configuration; + + return { + ...rest, + aggregateFieldMetadataId: getFieldMetadataIdOrThrow( + { + fieldMetadataId: aggregateFieldMetadataId, + fieldName: aggregateFieldName, + objectMetadataId, + maps, + }, + 'aggregate field', + ), + filter: resolveChartFilterFieldNamesToIds( + filter, + objectMetadataId, + maps, + ), + ratioAggregateConfig: isDefined(ratioAggregateConfig) + ? { + optionValue: ratioAggregateConfig.optionValue, + fieldMetadataId: getFieldMetadataIdOrThrow( + { + fieldMetadataId: ratioAggregateConfig.fieldMetadataId, + fieldName: ratioAggregateConfig.fieldName, + objectMetadataId, + maps, + }, + 'ratio aggregate field', + ), + } + : undefined, + }; + } + + case WidgetConfigurationType.BAR_CHART: + case WidgetConfigurationType.LINE_CHART: { + const { + aggregateFieldName, + aggregateFieldMetadataId, + primaryAxisGroupByFieldName, + primaryAxisGroupByFieldMetadataId, + secondaryAxisGroupByFieldName, + secondaryAxisGroupByFieldMetadataId, + filter, + ...rest + } = configuration; + + return { + ...rest, + aggregateFieldMetadataId: getFieldMetadataIdOrThrow( + { + fieldMetadataId: aggregateFieldMetadataId, + fieldName: aggregateFieldName, + objectMetadataId, + maps, + }, + 'aggregate field', + ), + filter: resolveChartFilterFieldNamesToIds( + filter, + objectMetadataId, + maps, + ), + primaryAxisGroupByFieldMetadataId: getFieldMetadataIdOrThrow( + { + fieldMetadataId: primaryAxisGroupByFieldMetadataId, + fieldName: primaryAxisGroupByFieldName, + objectMetadataId, + maps, + }, + 'primary axis group-by field', + ), + secondaryAxisGroupByFieldMetadataId: + isDefined(secondaryAxisGroupByFieldMetadataId) || + isNonEmptyString(secondaryAxisGroupByFieldName) + ? getFieldMetadataIdOrThrow( + { + fieldMetadataId: secondaryAxisGroupByFieldMetadataId, + fieldName: secondaryAxisGroupByFieldName, + objectMetadataId, + maps, + }, + 'secondary axis group-by field', + ) + : undefined, + }; + } + + case WidgetConfigurationType.PIE_CHART: { + const { + aggregateFieldName, + aggregateFieldMetadataId, + groupByFieldName, + groupByFieldMetadataId, + filter, + ...rest + } = configuration; + + return { + ...rest, + aggregateFieldMetadataId: getFieldMetadataIdOrThrow( + { + fieldMetadataId: aggregateFieldMetadataId, + fieldName: aggregateFieldName, + objectMetadataId, + maps, + }, + 'aggregate field', + ), + filter: resolveChartFilterFieldNamesToIds( + filter, + objectMetadataId, + maps, + ), + groupByFieldMetadataId: getFieldMetadataIdOrThrow( + { + fieldMetadataId: groupByFieldMetadataId, + fieldName: groupByFieldName, + objectMetadataId, + maps, + }, + 'group-by field', + ), + }; + } + + case WidgetConfigurationType.STANDALONE_RICH_TEXT: + return { + ...configuration, + body: { + ...configuration.body, + markdown: configuration.body.markdown ?? null, + }, + }; + case WidgetConfigurationType.IFRAME: + case WidgetConfigurationType.RECORD_TABLE: + return configuration; + } +}; + +export const resolveWidgetFieldNamesToIds = ( + widget: WidgetIdentifiersInput, + maps: DashboardIdentifierMaps, +): WidgetWithMetadataIds => { + const objectMetadataId = getObjectMetadataId({ + objectMetadataId: widget.objectMetadataId, + objectName: widget.objectName, + maps, + }); + + return { + title: widget.title, + type: widget.type, + gridPosition: widget.gridPosition, + objectMetadataId, + configuration: isDefined(widget.configuration) + ? resolveConfigurationFieldNamesToIds( + widget.configuration, + objectMetadataId, + maps, + ) + : undefined, + }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts.snap index 49f31a36e1..87a865b7e8 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts.snap @@ -64,19 +64,6 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT shou } `; -exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should throw error if view filter value is not a stringified JSON array 1`] = ` -[ - { - "extensions": { - "code": "INTERNAL_SERVER_ERROR", - "exceptionEventId": "mocked-exception-id", - "userFriendlyMessage": "An error occurred.", - }, - "message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5", - }, -] -`; - exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should update related multi selected options view filter 1`] = ` { "operand": "CONTAINS", @@ -174,19 +161,6 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should han } `; -exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should throw error if view filter value is not a stringified JSON array 1`] = ` -[ - { - "extensions": { - "code": "INTERNAL_SERVER_ERROR", - "exceptionEventId": "mocked-exception-id", - "userFriendlyMessage": "An error occurred.", - }, - "message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5", - }, -] -`; - exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should update related multi selected options view filter 1`] = ` { "operand": "IS", diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts index 3889180f61..007befb596 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts @@ -309,8 +309,6 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => { }, ); - // Note these test exists only because we do not validate the view filter value on creation/update - // Should be removed after https://github.com/twentyhq/core-team-issues/issues/1009 completion const failingTestCases: EachTestingContext<{ createViewFilterValue: unknown; }>[] = [ @@ -334,45 +332,24 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => { type: fieldType, }); - const viewFilterId = '20202020-e3b5-4fa7-85aa-9b1950fc7bf5'; - - await createOneViewFilter({ + const { errors } = await createOneViewFilter({ input: { - id: viewFilterId, + id: '20202020-e3b5-4fa7-85aa-9b1950fc7bf5', viewId: createdView.id, fieldMetadataId: createOneField.id, operand: operandForFieldType, value: createViewFilterValue as unknown as ViewFilterValue, }, - expectToFail: false, + expectToFail: true, gqlFields: ` id `, }); - const optionsWithIds = createOneField.options; - - if (!isDefined(optionsWithIds)) { - throw new Error('optionsWithIds is not defined'); - } - const updatePayload = { - options: optionsWithIds.map((option) => fakeOptionUpdate(option)), - }; - const { errors, data } = await updateOneFieldMetadata({ - expectToFail: true, - input: { - idToUpdate: createOneField.id, - updatePayload, - }, - gqlFields: ` - id - options - `, - }); - - expect(data).toBeNull(); expect(errors).toBeDefined(); - expect(errors).toMatchSnapshot(); + expect(errors![0].extensions.code).toBe( + 'METADATA_VALIDATION_FAILED', + ); }, ); });