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
@@ -326,7 +326,7 @@ ${hasWebSearch ? '3' : '2'}. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NA
case ToolCategory.METADATA:
return 'Metadata Tools (schema management)';
case ToolCategory.VIEW:
return 'View Tools (query views)';
return 'View Tools (manage views, filters, and sorts)';
case ToolCategory.DASHBOARD:
return 'Dashboard Tools (create/manage dashboards)';
case ToolCategory.LOGIC_FUNCTION:
@@ -188,6 +188,13 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
]);
}
if (isDefined(updateObjectInput.update.isActive)) {
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
});
}
return updatedFlatObjectMetadata;
}
@@ -0,0 +1,271 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { ViewFilterOperand } from 'twenty-shared/types';
import { z } from 'zod';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const VIEW_FILTER_OPERAND_OPTIONS = Object.values(ViewFilterOperand);
const GetViewFiltersInputSchema = z.object({
viewId: z
.string()
.uuid()
.describe(
'ID of the view to list filters for. Obtain this from get_views.',
),
});
const CreateViewFilterInputSchema = z.object({
viewId: z.string().uuid().describe('ID of the view to add the filter to'),
fieldMetadataId: z
.string()
.uuid()
.describe(
'ID of the field to filter on. Use list_object_metadata_items to find field IDs.',
),
operand: z
.enum(VIEW_FILTER_OPERAND_OPTIONS)
.describe(
'Filter operator. Valid operators per field type — TEXT/EMAILS/FULL_NAME: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY. NUMBER/NUMERIC: IS, IS_NOT, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS_EMPTY, IS_NOT_EMPTY. CURRENCY: GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, 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/ARRAY: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY. RELATION: IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY. BOOLEAN: IS.',
),
value: z
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string(), z.unknown()),
])
.describe(
'Filter value. Format depends on operand and field type: string for TEXT/SELECT, number for NUMBER/CURRENCY, array of option values for MULTI_SELECT IS/IS_NOT, empty string "" for IS_EMPTY/IS_NOT_EMPTY operators.',
),
subFieldName: z
.string()
.optional()
.describe(
'Required for composite fields — e.g. "amountMicros" for CURRENCY, "addressCity" for ADDRESS, "firstName" or "lastName" for FULL_NAME.',
),
});
const CreateManyViewFiltersInputSchema = z.object({
filters: z
.array(CreateViewFilterInputSchema)
.min(1)
.max(20)
.describe('Array of filters to create (1-20 items)'),
});
const UpdateViewFilterInputSchema = z.object({
id: z.string().uuid().describe('ID of the view filter to update'),
operand: z
.enum(VIEW_FILTER_OPERAND_OPTIONS)
.optional()
.describe('New filter operator'),
value: z
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string(), z.unknown()),
])
.optional()
.describe('New filter value'),
subFieldName: z
.string()
.optional()
.describe('New sub-field name for composite fields'),
});
const DeleteViewFilterInputSchema = z.object({
id: z.string().uuid().describe('ID of the view filter to delete'),
});
@Injectable()
export class ViewFilterToolsFactory {
constructor(private readonly viewFilterService: ViewFilterService) {}
generateReadTools(workspaceId: string): ToolSet {
return {
get_view_filters: {
description:
'List all filters applied to a view. Each filter defines a condition that records must match to appear in the view.',
inputSchema: GetViewFiltersInputSchema,
execute: async (parameters: { viewId: string }) => {
const filters = await this.viewFilterService.findByViewId(
workspaceId,
parameters.viewId,
);
return filters.map((filter) => ({
id: filter.id,
viewId: filter.viewId,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
subFieldName: filter.subFieldName,
positionInViewFilterGroup: filter.positionInViewFilterGroup,
}));
},
},
};
}
generateWriteTools(workspaceId: string): ToolSet {
return {
create_view_filter: {
description:
'Add a filter to a view. Use list_object_metadata_items to get fieldMetadataId values.',
inputSchema: CreateViewFilterInputSchema,
execute: async (parameters: {
viewId: string;
fieldMetadataId: string;
operand: ViewFilterOperand;
value: unknown;
subFieldName?: string;
}) => {
try {
const filter = await this.viewFilterService.createOne({
createViewFilterInput: {
viewId: parameters.viewId,
fieldMetadataId: parameters.fieldMetadataId,
operand: parameters.operand,
value: parameters.value as ViewFilterValue,
subFieldName: parameters.subFieldName,
},
workspaceId,
});
return {
id: filter.id,
viewId: filter.viewId,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
create_many_view_filters: {
description:
'Add multiple filters to a view in one call. Use list_object_metadata_items to get fieldMetadataId values.',
inputSchema: CreateManyViewFiltersInputSchema,
execute: async (parameters: {
filters: Array<{
viewId: string;
fieldMetadataId: string;
operand: ViewFilterOperand;
value: unknown;
subFieldName?: string;
}>;
}) => {
const results = [];
for (const filterInput of parameters.filters) {
try {
const filter = await this.viewFilterService.createOne({
createViewFilterInput: {
viewId: filterInput.viewId,
fieldMetadataId: filterInput.fieldMetadataId,
operand: filterInput.operand,
value: filterInput.value as ViewFilterValue,
subFieldName: filterInput.subFieldName,
},
workspaceId,
});
results.push({
id: filter.id,
viewId: filter.viewId,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
});
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
}
return { created: results };
},
},
update_view_filter: {
description:
'Update a filter on a view. Use get_view_filters to find the filter ID.',
inputSchema: UpdateViewFilterInputSchema,
execute: async (parameters: {
id: string;
operand?: ViewFilterOperand;
value?: unknown;
subFieldName?: string;
}) => {
try {
const filter = await this.viewFilterService.updateOne({
updateViewFilterInput: {
id: parameters.id,
update: {
operand: parameters.operand,
value: parameters.value as ViewFilterValue,
subFieldName: parameters.subFieldName,
},
},
workspaceId,
});
return {
id: filter.id,
viewId: filter.viewId,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
delete_view_filter: {
description:
'Remove a filter from a view. Use get_view_filters to find the filter ID.',
inputSchema: DeleteViewFilterInputSchema,
execute: async (parameters: { id: string }) => {
try {
const filter = await this.viewFilterService.deleteOne({
deleteViewFilterInput: { id: parameters.id },
workspaceId,
});
return {
id: filter.id,
deleted: true,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}
@@ -8,6 +8,7 @@ import { ViewFilterController } from 'src/engine/metadata-modules/view-filter/co
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { ViewFilterResolver } from 'src/engine/metadata-modules/view-filter/resolvers/view-filter.resolver';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewFilterToolsFactory } from 'src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory';
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -24,7 +25,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
ViewPermissionsModule,
],
controllers: [ViewFilterController],
providers: [ViewFilterService, ViewFilterResolver],
exports: [ViewFilterService],
providers: [ViewFilterService, ViewFilterResolver, ViewFilterToolsFactory],
exports: [ViewFilterService, ViewFilterToolsFactory],
})
export class ViewFilterModule {}
@@ -0,0 +1,219 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const VIEW_SORT_DIRECTION_OPTIONS = Object.values(ViewSortDirection);
const GetViewSortsInputSchema = z.object({
viewId: z
.string()
.uuid()
.describe('ID of the view to list sorts for. Obtain this from get_views.'),
});
const CreateViewSortInputSchema = z.object({
viewId: z.string().uuid().describe('ID of the view to add the sort to'),
fieldMetadataId: z
.string()
.uuid()
.describe(
'ID of the field to sort by. Use list_object_metadata_items to find field IDs.',
),
direction: z
.enum(VIEW_SORT_DIRECTION_OPTIONS)
.default(ViewSortDirection.ASC)
.describe('Sort direction: ASC (ascending) or DESC (descending)'),
});
const CreateManyViewSortsInputSchema = z.object({
sorts: z
.array(CreateViewSortInputSchema)
.min(1)
.max(10)
.describe('Array of sorts to create (1-10 items)'),
});
const UpdateViewSortInputSchema = z.object({
id: z.string().uuid().describe('ID of the view sort to update'),
direction: z
.enum(VIEW_SORT_DIRECTION_OPTIONS)
.optional()
.describe('New sort direction: ASC or DESC'),
});
const DeleteViewSortInputSchema = z.object({
id: z.string().uuid().describe('ID of the view sort to delete'),
});
@Injectable()
export class ViewSortToolsFactory {
constructor(private readonly viewSortService: ViewSortService) {}
generateReadTools(workspaceId: string): ToolSet {
return {
get_view_sorts: {
description:
'List all sorts applied to a view. Each sort defines a field and direction that determines the order records appear in the view.',
inputSchema: GetViewSortsInputSchema,
execute: async (parameters: { viewId: string }) => {
const sorts = await this.viewSortService.findByViewId(
workspaceId,
parameters.viewId,
);
return sorts.map((sort) => ({
id: sort.id,
viewId: sort.viewId,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
}));
},
},
};
}
generateWriteTools(workspaceId: string): ToolSet {
return {
create_view_sort: {
description:
'Add a sort to a view. Use list_object_metadata_items to get fieldMetadataId values.',
inputSchema: CreateViewSortInputSchema,
execute: async (parameters: {
viewId: string;
fieldMetadataId: string;
direction: ViewSortDirection;
}) => {
try {
const sort = await this.viewSortService.createOne({
createViewSortInput: {
viewId: parameters.viewId,
fieldMetadataId: parameters.fieldMetadataId,
direction: parameters.direction,
},
workspaceId,
});
return {
id: sort.id,
viewId: sort.viewId,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
create_many_view_sorts: {
description:
'Add multiple sorts to a view in one call. Use list_object_metadata_items to get fieldMetadataId values.',
inputSchema: CreateManyViewSortsInputSchema,
execute: async (parameters: {
sorts: Array<{
viewId: string;
fieldMetadataId: string;
direction: ViewSortDirection;
}>;
}) => {
const results = [];
for (const sortInput of parameters.sorts) {
try {
const sort = await this.viewSortService.createOne({
createViewSortInput: {
viewId: sortInput.viewId,
fieldMetadataId: sortInput.fieldMetadataId,
direction: sortInput.direction,
},
workspaceId,
});
results.push({
id: sort.id,
viewId: sort.viewId,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
});
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
}
return { created: results };
},
},
update_view_sort: {
description:
'Update a sort on a view. Use get_view_sorts to find the sort ID.',
inputSchema: UpdateViewSortInputSchema,
execute: async (parameters: {
id: string;
direction?: ViewSortDirection;
}) => {
try {
const sort = await this.viewSortService.updateOne({
updateViewSortInput: {
id: parameters.id,
update: {
direction: parameters.direction,
},
},
workspaceId,
});
return {
id: sort.id,
viewId: sort.viewId,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
delete_view_sort: {
description:
'Remove a sort from a view. Use get_view_sorts to find the sort ID.',
inputSchema: DeleteViewSortInputSchema,
execute: async (parameters: { id: string }) => {
try {
const sort = await this.viewSortService.deleteOne({
deleteViewSortInput: { id: parameters.id },
workspaceId,
});
return {
id: sort.id,
deleted: true,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}
@@ -8,6 +8,7 @@ import { ViewSortController } from 'src/engine/metadata-modules/view-sort/contro
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { ViewSortResolver } from 'src/engine/metadata-modules/view-sort/resolvers/view-sort.resolver';
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
import { ViewSortToolsFactory } from 'src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -24,7 +25,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
ViewPermissionsModule,
],
controllers: [ViewSortController],
providers: [ViewSortService, ViewSortResolver],
exports: [ViewSortService],
providers: [ViewSortService, ViewSortResolver, ViewSortToolsFactory],
exports: [ViewSortService, ViewSortToolsFactory],
})
export class ViewSortModule {}
@@ -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,
@@ -8,6 +8,8 @@ import { FlatViewModule } from 'src/engine/metadata-modules/flat-view/flat-view.
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewFieldGroupModule } from 'src/engine/metadata-modules/view-field-group/view-field-group.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
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 { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller';
@@ -24,6 +26,8 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
TypeOrmModule.forFeature([ViewEntity]),
ViewPermissionsModule,
ViewFieldGroupModule,
ViewFieldModule,
ViewFilterModule,
ViewSortModule,
I18nModule,
ApplicationModule,