Refined demo workspace creation skill (rebased, review fixes) (#19274)

## Summary

Rebased version of #19051 with all review comments addressed. Clean
branch on latest main, lint/typecheck/tests passing.

### Changes from original PR
- AI can now create, update, and delete **view filters**
(`ViewFilterToolsFactory`) and **view sorts** (`ViewSortToolsFactory`)
- `create_view` now accepts `calendarFieldName`, `calendarLayout`, and
`fieldNames` to configure views at creation time
- Three new standard skills: `view-building`, `view-filters-and-sorts`,
`custom-objects-cleanup`
- `workspace-demo-seeding` skill reworked to keep standard objects and
enrich them with custom fields
- Cache invalidation for nav menu items when object `isActive` changes
- Dashboard tool descriptions improved (RECORD_TABLE widget workflow)

### Review comments addressed (all 10 from #19051)
1. **Sentry + Cubic**: Calendar field DATE/DATE_TIME validation — added
`resolveCalendarFieldMetadataId` using `isFieldMetadataDateKind`
2. **Cubic**: "navigate tool" → "navigate_app tool" in skill metadata
(all 7 occurrences)
3. **Copilot**: KANBAN views now require `mainGroupByFieldName` — throws
clear error if missing
4. **Copilot**: CALENDAR views now require both `calendarFieldName` and
`calendarLayout` — validated before DB call
5. **Copilot**: Mock field fixtures include `type` property (DATE_TIME,
TEXT, SELECT)
6. **Copilot**: `ViewFilterValue` type assertion instead of unsafe `as
string` casts (3 locations)
7. **FelixMalfait**: Removed
`NavigationMenuItemObjectDeactivationListener` — replaced with cache
invalidation
8. **FelixMalfait**: Consolidated `ViewFilterToolProvider` and
`ViewSortToolProvider` into single `ViewToolProvider`
9. Removed `VIEW_FILTER` and `VIEW_SORT` from `ToolCategory` enum
(merged into `VIEW`)
10. Removed stale `existingFeatureFlagsMap` param incompatible with
current main

## Test plan
- [x] `npx nx lint:diff-with-main twenty-server` — passes
- [x] `npx nx typecheck twenty-server` — passes
- [x] `view-tools.factory.spec.ts` — all 20 tests pass (including 3 new
validation tests)

Supersedes #19051

https://claude.ai/code/session_01QPV74NU6vzmJb32e4i899E

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-03 09:19:46 +02:00
committed by GitHub
parent 12b031b67d
commit bb3d556799
20 changed files with 1689 additions and 360 deletions
@@ -1,12 +1,16 @@
import { Test, type TestingModule } from '@nestjs/testing';
import {
FieldMetadataType,
OrderByDirection,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
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 { 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';
@@ -14,6 +18,7 @@ import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-to
describe('ViewToolsFactory', () => {
let viewToolsFactory: ViewToolsFactory;
let viewService: jest.Mocked<ViewService>;
let viewFieldService: jest.Mocked<ViewFieldService>;
let viewQueryParamsService: jest.Mocked<ViewQueryParamsService>;
let _flatEntityMapsCacheService: jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>;
@@ -22,6 +27,36 @@ describe('ViewToolsFactory', () => {
const mockViewId = 'view-id';
const mockObjectMetadataId = 'object-metadata-id';
const mockObjectNameSingular = 'company';
const mockCalendarFieldMetadataId = 'calendar-field-metadata-id';
const mockNameFieldMetadataId = 'name-field-metadata-id';
const mockStageFieldMetadataId = 'stage-field-metadata-id';
const mockFlatFieldMetadataMaps = {
byUniversalIdentifier: {
'field-universal-id': {
id: mockCalendarFieldMetadataId,
name: 'dueAt',
type: FieldMetadataType.DATE_TIME,
objectMetadataId: mockObjectMetadataId,
universalIdentifier: 'field-universal-id',
},
'name-field-universal-id': {
id: mockNameFieldMetadataId,
name: 'name',
type: FieldMetadataType.TEXT,
objectMetadataId: mockObjectMetadataId,
universalIdentifier: 'name-field-universal-id',
},
'stage-field-universal-id': {
id: mockStageFieldMetadataId,
name: 'stage',
type: FieldMetadataType.SELECT,
objectMetadataId: mockObjectMetadataId,
universalIdentifier: 'stage-field-universal-id',
},
},
};
const mockView = {
id: mockViewId,
@@ -70,6 +105,12 @@ describe('ViewToolsFactory', () => {
deleteOne: jest.fn(),
},
},
{
provide: ViewFieldService,
useValue: {
createMany: jest.fn().mockResolvedValue([]),
},
},
{
provide: ViewQueryParamsService,
useValue: {
@@ -81,6 +122,7 @@ describe('ViewToolsFactory', () => {
useValue: {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
flatFieldMetadataMaps: mockFlatFieldMetadataMaps,
}),
},
},
@@ -89,6 +131,7 @@ describe('ViewToolsFactory', () => {
viewToolsFactory = module.get<ViewToolsFactory>(ViewToolsFactory);
viewService = module.get(ViewService);
viewFieldService = module.get(ViewFieldService);
viewQueryParamsService = module.get(ViewQueryParamsService);
_flatEntityMapsCacheService = module.get(
WorkspaceManyOrAllFlatEntityMapsCacheService,
@@ -275,6 +318,175 @@ describe('ViewToolsFactory', () => {
visibility: ViewVisibility.WORKSPACE,
});
});
it('should create view fields when fieldNames is provided', async () => {
const createdView = {
id: 'new-view-id',
name: 'Kanban View',
objectMetadataId: mockObjectMetadataId,
type: ViewType.KANBAN,
icon: 'IconLayoutKanban',
visibility: ViewVisibility.WORKSPACE,
};
viewService.createOne.mockResolvedValue(createdView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await callExecute(tools['create_view'], {
name: 'Kanban View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconLayoutKanban',
type: ViewType.KANBAN,
mainGroupByFieldName: 'stage',
fieldNames: ['name', 'stage'],
});
expect(viewFieldService.createMany).toHaveBeenCalledWith({
createViewFieldInputs: [
{
viewId: 'new-view-id',
fieldMetadataId: mockNameFieldMetadataId,
isVisible: true,
size: 150,
position: 0,
},
{
viewId: 'new-view-id',
fieldMetadataId: mockStageFieldMetadataId,
isVisible: true,
size: 150,
position: 1,
},
],
workspaceId: mockWorkspaceId,
});
});
it('should throw when KANBAN view missing mainGroupByFieldName', async () => {
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await expect(
callExecute(tools['create_view'], {
name: 'Kanban View',
objectNameSingular: mockObjectNameSingular,
type: ViewType.KANBAN,
}),
).rejects.toThrow('KANBAN views require mainGroupByFieldName');
});
it('should throw when CALENDAR view missing calendarFieldName', async () => {
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await expect(
callExecute(tools['create_view'], {
name: 'Calendar View',
objectNameSingular: mockObjectNameSingular,
type: ViewType.CALENDAR,
calendarLayout: ViewCalendarLayout.WEEK,
}),
).rejects.toThrow('CALENDAR views require calendarFieldName');
});
it('should throw when CALENDAR view missing calendarLayout', async () => {
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await expect(
callExecute(tools['create_view'], {
name: 'Calendar View',
objectNameSingular: mockObjectNameSingular,
type: ViewType.CALENDAR,
calendarFieldName: 'dueAt',
}),
).rejects.toThrow('CALENDAR views require calendarLayout');
});
it('should not create view fields when fieldNames is not provided', async () => {
const createdView = {
id: 'new-view-id',
name: 'New View',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
icon: 'IconTable',
visibility: ViewVisibility.WORKSPACE,
};
viewService.createOne.mockResolvedValue(createdView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await callExecute(tools['create_view'], {
name: 'New View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconTable',
});
expect(viewFieldService.createMany).not.toHaveBeenCalled();
});
it('should create a calendar view with layout and field', async () => {
const createdView = {
id: 'new-calendar-view-id',
name: 'Calendar View',
objectMetadataId: mockObjectMetadataId,
type: ViewType.CALENDAR,
icon: 'IconCalendar',
visibility: ViewVisibility.WORKSPACE,
};
viewService.createOne.mockResolvedValue(createdView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
const result = await callExecute(tools['create_view'], {
name: 'Calendar View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconCalendar',
type: ViewType.CALENDAR,
calendarLayout: ViewCalendarLayout.WEEK,
calendarFieldName: 'dueAt',
});
expect(viewService.createOne).toHaveBeenCalledWith({
createViewInput: {
name: 'Calendar View',
objectMetadataId: mockObjectMetadataId,
icon: 'IconCalendar',
type: ViewType.CALENDAR,
visibility: ViewVisibility.WORKSPACE,
calendarLayout: ViewCalendarLayout.WEEK,
calendarFieldMetadataId: mockCalendarFieldMetadataId,
},
workspaceId: mockWorkspaceId,
createdByUserWorkspaceId: mockUserWorkspaceId,
});
expect(result).toEqual({
id: 'new-calendar-view-id',
name: 'Calendar View',
objectNameSingular: mockObjectNameSingular,
type: ViewType.CALENDAR,
icon: 'IconCalendar',
visibility: ViewVisibility.WORKSPACE,
});
});
});
describe('update-view tool', () => {
@@ -1,19 +1,24 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { z } from 'zod';
import {
AggregateOperations,
FieldMetadataType,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
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 { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
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';
const GetViewsInputSchema = z.object({
objectNameSingular: z
@@ -76,6 +81,28 @@ const CreateViewInputSchema = z.object({
.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 views, e.g., "DAY", "WEEK", "MONTH")',
),
calendarFieldName: z
.string()
.optional()
.describe(
'Date field name to use for the calendar (required for CALENDAR views, must be a DATE or DATE_TIME field, e.g., "createdAt", "dueAt")',
),
fieldNames: z
.array(z.string())
.optional()
.describe(
'Field names to display in the view as columns (for TABLE) or cards (for KANBAN/CALENDAR). Fields are displayed in the order provided. Use get_field_metadata to find available field names.',
),
});
const UpdateViewInputSchema = z.object({
@@ -92,6 +119,7 @@ const DeleteViewInputSchema = z.object({
export class ViewToolsFactory {
constructor(
private readonly viewService: ViewService,
private readonly viewFieldService: ViewFieldService,
private readonly viewQueryParamsService: ViewQueryParamsService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
@@ -153,6 +181,78 @@ export class ViewToolsFactory {
return fieldMetadata.id;
}
private async resolveGroupByFieldMetadataId(
workspaceId: string,
objectMetadataId: string,
fieldName: string,
): Promise<string> {
const { flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const fieldMetadata = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).find(
(field) =>
field?.name === fieldName &&
field?.objectMetadataId === objectMetadataId,
);
if (!fieldMetadata) {
throw new Error(
`Field "${fieldName}" not found on this object. Use get_field_metadata to list available fields.`,
);
}
if (fieldMetadata.type !== FieldMetadataType.SELECT) {
throw new Error(
`Field "${fieldName}" has type "${fieldMetadata.type}" and cannot be used as a group-by field. Only SELECT fields are supported for grouping (board columns and table groups).`,
);
}
return fieldMetadata.id;
}
private async resolveCalendarFieldMetadataId(
workspaceId: string,
objectMetadataId: string,
fieldName: string,
): Promise<string> {
const { flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const fieldMetadata = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).find(
(field) =>
field?.name === fieldName &&
field?.objectMetadataId === objectMetadataId,
);
if (!fieldMetadata) {
throw new Error(
`Field "${fieldName}" not found on this object. Use get_field_metadata to list available fields.`,
);
}
if (!isFieldMetadataDateKind(fieldMetadata.type)) {
throw new Error(
`Field "${fieldName}" has type "${fieldMetadata.type}" and cannot be used as a calendar field. Only DATE or DATE_TIME fields are supported.`,
);
}
return fieldMetadata.id;
}
generateReadTools(
workspaceId: string,
userWorkspaceId?: string,
@@ -219,7 +319,7 @@ export class ViewToolsFactory {
return {
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").',
'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.',
inputSchema: CreateViewInputSchema,
execute: async (parameters: {
name: string;
@@ -230,6 +330,9 @@ export class ViewToolsFactory {
mainGroupByFieldName?: string;
kanbanAggregateOperation?: string;
kanbanAggregateOperationFieldName?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldName?: string;
fieldNames?: string[];
}) => {
try {
const objectMetadataId = await this.resolveObjectMetadataId(
@@ -237,15 +340,40 @@ export class ViewToolsFactory {
parameters.objectNameSingular,
);
if (
parameters.type === ViewType.KANBAN &&
!parameters.mainGroupByFieldName
) {
throw new Error(
'KANBAN views require mainGroupByFieldName. Provide a SELECT field name (e.g., "stage", "status") to group records into columns.',
);
}
if (parameters.type === ViewType.CALENDAR) {
if (!parameters.calendarFieldName) {
throw new Error(
'CALENDAR views require calendarFieldName. Provide a DATE or DATE_TIME field name (e.g., "dueAt", "createdAt").',
);
}
if (!parameters.calendarLayout) {
throw new Error(
'CALENDAR views require calendarLayout. Provide one of: "DAY", "WEEK", "MONTH".',
);
}
}
let mainGroupByFieldMetadataId: string | undefined;
let kanbanAggregateOperationFieldMetadataId: string | undefined;
let calendarFieldMetadataId: string | undefined;
if (parameters.mainGroupByFieldName) {
mainGroupByFieldMetadataId = await this.resolveFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.mainGroupByFieldName,
);
mainGroupByFieldMetadataId =
await this.resolveGroupByFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.mainGroupByFieldName,
);
}
if (parameters.kanbanAggregateOperationFieldName) {
@@ -257,6 +385,15 @@ export class ViewToolsFactory {
);
}
if (parameters.calendarFieldName) {
calendarFieldMetadataId =
await this.resolveCalendarFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.calendarFieldName,
);
}
const view = await this.viewService.createOne({
createViewInput: {
name: parameters.name,
@@ -268,11 +405,38 @@ export class ViewToolsFactory {
kanbanAggregateOperation:
parameters.kanbanAggregateOperation as AggregateOperations,
kanbanAggregateOperationFieldMetadataId,
calendarLayout: parameters.calendarLayout,
calendarFieldMetadataId,
},
workspaceId,
createdByUserWorkspaceId: userWorkspaceId,
});
if (isNonEmptyArray(parameters.fieldNames)) {
const resolvedFieldMetadataIds = await Promise.all(
parameters.fieldNames.map((fieldName) =>
this.resolveFieldMetadataId(
workspaceId,
objectMetadataId,
fieldName,
),
),
);
await this.viewFieldService.createMany({
createViewFieldInputs: resolvedFieldMetadataIds.map(
(fieldMetadataId, index) => ({
viewId: view.id,
fieldMetadataId,
isVisible: true,
size: 150,
position: index,
}),
),
workspaceId,
});
}
return {
id: view.id,
name: view.name,