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
@@ -16,6 +16,8 @@ import {
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewFilterToolsFactory } from 'src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory';
import { ViewSortToolsFactory } from 'src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
@Injectable()
@@ -24,23 +26,31 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
constructor(
private readonly viewToolsFactory: ViewToolsFactory,
private readonly viewFilterToolsFactory: ViewFilterToolsFactory,
private readonly viewSortToolsFactory: ViewSortToolsFactory,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
const factory = this.viewToolsFactory;
const viewFactory = this.viewToolsFactory;
const filterFactory = this.viewFilterToolsFactory;
const sortFactory = this.viewSortToolsFactory;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.VIEW,
async (context) => {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const readTools = factory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
);
const readTools = {
...viewFactory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
),
...filterFactory.generateReadTools(context.workspaceId),
...sortFactory.generateReadTools(context.workspaceId),
};
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
@@ -50,10 +60,14 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
);
if (hasViewPermission) {
const writeTools = factory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
);
const writeTools = {
...viewFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
),
...filterFactory.generateWriteTools(context.workspaceId),
...sortFactory.generateWriteTools(context.workspaceId),
};
return { ...readTools, ...writeTools };
}
@@ -74,14 +88,17 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const schemaOptions = {
includeSchemas: options?.includeSchemas ?? true,
icon: 'IconTable',
};
const readTools = this.viewToolsFactory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
);
const readTools = {
...this.viewToolsFactory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
),
...this.viewFilterToolsFactory.generateReadTools(context.workspaceId),
...this.viewSortToolsFactory.generateReadTools(context.workspaceId),
};
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
@@ -91,10 +108,14 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
);
if (hasViewPermission) {
const writeTools = this.viewToolsFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
);
const writeTools = {
...this.viewToolsFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
),
...this.viewFilterToolsFactory.generateWriteTools(context.workspaceId),
...this.viewSortToolsFactory.generateWriteTools(context.workspaceId),
};
return toolSetToDescriptors(
{ ...readTools, ...writeTools },
@@ -25,6 +25,8 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.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 { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -48,6 +50,8 @@ import { ToolRegistryService } from './services/tool-registry.service';
PermissionsModule,
ViewModule,
ViewFieldModule,
ViewFilterModule,
ViewSortModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
LogicFunctionModule,
@@ -19,6 +19,7 @@ import {
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@@ -182,66 +183,80 @@ export class NavigateAppTool implements Tool {
},
);
const availableObjectNames = navigationMenuItems
.map((navigationMenuItem) => {
if (isDefined(navigationMenuItem.viewId)) {
const correspondingViewUniversalIdentifier =
flatViewMaps.universalIdentifierById[navigationMenuItem.viewId];
type NavigatableObject = {
nameSingular: string;
labelSingular: string;
labelPlural: string;
};
if (isDefined(correspondingViewUniversalIdentifier)) {
const correspondingView =
flatViewMaps.byUniversalIdentifier[
correspondingViewUniversalIdentifier
];
const navigatableObjects = navigationMenuItems
.map<NavigatableObject | null>((navigationMenuItem) => {
const objectMetadataId =
navigationMenuItem.type === NavigationMenuItemType.OBJECT
? navigationMenuItem.targetObjectMetadataId
: navigationMenuItem.type === NavigationMenuItemType.VIEW &&
isDefined(navigationMenuItem.viewId)
? flatViewMaps.byUniversalIdentifier[
flatViewMaps.universalIdentifierById[
navigationMenuItem.viewId
] ?? ''
]?.objectMetadataId
: undefined;
if (isDefined(correspondingView)) {
const correspondingObjectMetadataUniversalIdentifier =
flatObjectMetadataMaps.universalIdentifierById[
correspondingView.objectMetadataId
];
if (isDefined(correspondingObjectMetadataUniversalIdentifier)) {
const correspondingObjectMetadata =
flatObjectMetadataMaps.byUniversalIdentifier[
correspondingObjectMetadataUniversalIdentifier
];
if (isDefined(correspondingObjectMetadata)) {
const correspondingObjectNameSingular =
correspondingObjectMetadata.nameSingular;
return correspondingObjectNameSingular;
}
}
}
}
if (!isDefined(objectMetadataId)) {
return null;
}
return null;
const objectMetadataUniversalIdentifier =
flatObjectMetadataMaps.universalIdentifierById[objectMetadataId];
if (!isDefined(objectMetadataUniversalIdentifier)) {
return null;
}
const objectMetadata =
flatObjectMetadataMaps.byUniversalIdentifier[
objectMetadataUniversalIdentifier
];
if (!isDefined(objectMetadata)) {
return null;
}
return {
nameSingular: objectMetadata.nameSingular,
labelSingular: objectMetadata.labelSingular,
labelPlural: objectMetadata.labelPlural,
};
})
.filter(isDefined);
const fuse = new Fuse(availableObjectNames, {
threshold: 0.6,
const fuse = new Fuse(navigatableObjects, {
keys: ['nameSingular', 'labelSingular', 'labelPlural'],
threshold: 0.4,
});
const results = fuse.search(objectNameSingular.replace(/\s/g, ''));
const firstMatchingNavigationItemLabel = results[0]?.item;
const results = fuse.search(objectNameSingular);
const matchingObject = results[0]?.item;
if (!isDefined(matchingObject)) {
const availableLabels = navigatableObjects
.map((object) => object.labelPlural)
.join(', ');
if (!isDefined(firstMatchingNavigationItemLabel)) {
return {
success: false,
message: `Object "${objectNameSingular}" not found`,
error: `No object with singular name "${objectNameSingular}" was found in this workspace. Available objects: ${availableObjectNames}`,
error: `No object matching "${objectNameSingular}" was found in this workspace. Available objects: ${availableLabels}`,
};
}
return {
success: true,
message: `Navigating to ${firstMatchingNavigationItemLabel} default view`,
message: `Navigating to ${matchingObject.labelPlural} default view`,
result: {
action: 'navigateToObject',
objectNameSingular: firstMatchingNavigationItemLabel,
objectNameSingular: matchingObject.nameSingular,
},
};
}