feat(ai): add view management tools for AI chat (#16495)

## Summary

Adds a new **VIEW** tool category for the AI chat, enabling it to work
with views:

- **get-views**: List views in the workspace, optionally filtered by
object metadata ID
- **get-view-query-parameters**: Convert a view's filters and sorts into
GraphQL query parameters that can be passed to existing `find_*` data
tools
- **create-view**, **update-view**, **delete-view**: CRUD operations for
view management

### Key design decisions

1. **No pagination duplication**: Instead of creating a
`find-records-from-view` tool that would duplicate pagination logic,
`get-view-query-parameters` returns filter/sort parameters that the AI
can pass to existing record-fetching tools.

2. **Permission model**:
- Read tools (get-views, get-view-query-parameters) are available to all
users
   - Write tools require the `VIEW` permission
   - UNLISTED views can only be modified by their creator

3. **Leverages existing utilities**: Uses
`computeRecordGqlOperationFilter` from `twenty-shared` for filter
conversion.

### Files changed

- Added `ViewToolProvider`, `ViewToolsFactory`, and
`ViewQueryParamsService`
- Added `VIEW` to `ToolCategory` enum and tool registry
- Updated `chat-execution.service.ts` to include view tools in the
catalog and pass viewId in browsing context
- Extracted shared `formatValidationErrors` utility to reduce
duplication

## Test plan

- [x] Unit tests for `ViewToolsFactory` 
- [x] Unit tests for `ViewQueryParamsService`
- [x] Lint and typecheck pass
This commit is contained in:
Félix Malfait
2025-12-11 15:05:42 +01:00
committed by GitHub
parent 2943125410
commit 3cea19baf4
15 changed files with 1384 additions and 140 deletions
@@ -0,0 +1,254 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.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';
describe('ViewQueryParamsService', () => {
let viewQueryParamsService: ViewQueryParamsService;
let viewService: jest.Mocked<ViewService>;
let flatEntityMapsCacheService: jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>;
const mockWorkspaceId = 'workspace-id';
const mockViewId = 'view-id';
const mockObjectMetadataId = 'object-metadata-id';
const mockFieldMetadataId = 'field-metadata-id';
const mockFlatObjectMetadataMaps = {
byId: {
[mockObjectMetadataId]: {
id: mockObjectMetadataId,
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
},
},
byNameSingular: {},
byNamePlural: {},
};
const mockFlatFieldMetadataMaps = {
byId: {
[mockFieldMetadataId]: {
id: mockFieldMetadataId,
name: 'name',
type: FieldMetadataType.TEXT,
label: 'Name',
options: null,
},
},
byNameAndObjectId: {},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ViewQueryParamsService,
{
provide: ViewService,
useValue: {
findById: jest.fn(),
},
},
{
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
useValue: {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn(),
},
},
],
}).compile();
viewQueryParamsService = module.get<ViewQueryParamsService>(
ViewQueryParamsService,
);
viewService = module.get(ViewService);
flatEntityMapsCacheService = module.get(
WorkspaceManyOrAllFlatEntityMapsCacheService,
);
});
it('should be defined', () => {
expect(viewQueryParamsService).toBeDefined();
});
describe('resolveViewToQueryParams', () => {
it('should throw error when view is not found', async () => {
viewService.findById.mockResolvedValue(null);
await expect(
viewQueryParamsService.resolveViewToQueryParams(
mockViewId,
mockWorkspaceId,
),
).rejects.toThrow(`View with id ${mockViewId} not found`);
});
it('should return query params for a view without filters or sorts', async () => {
const mockView = {
id: mockViewId,
name: 'All Companies',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
visibility: ViewVisibility.WORKSPACE,
viewFilters: [],
viewFilterGroups: [],
viewSorts: [],
};
viewService.findById.mockResolvedValue(mockView as any);
flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
flatFieldMetadataMaps: mockFlatFieldMetadataMaps,
} as any,
);
const result = await viewQueryParamsService.resolveViewToQueryParams(
mockViewId,
mockWorkspaceId,
);
expect(result.objectNameSingular).toBe('company');
expect(result.filter).toEqual({});
expect(result.orderBy).toEqual([]);
expect(result.viewName).toBe('All Companies');
expect(result.viewType).toBe(ViewType.TABLE);
});
it('should return query params with filters', async () => {
const mockFilterGroupId = 'filter-group-id';
const mockView = {
id: mockViewId,
name: 'Companies with Name',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
visibility: ViewVisibility.WORKSPACE,
viewFilters: [
{
id: 'filter-id',
fieldMetadataId: mockFieldMetadataId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
viewFilterGroupId: mockFilterGroupId,
subFieldName: null,
},
],
viewFilterGroups: [
{
id: mockFilterGroupId,
parentViewFilterGroupId: null,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
},
],
viewSorts: [],
};
viewService.findById.mockResolvedValue(mockView as any);
flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
flatFieldMetadataMaps: mockFlatFieldMetadataMaps,
} as any,
);
const result = await viewQueryParamsService.resolveViewToQueryParams(
mockViewId,
mockWorkspaceId,
);
expect(result.objectNameSingular).toBe('company');
expect(result.viewName).toBe('Companies with Name');
expect(result.filter).toBeDefined();
});
it('should return query params with sorts', async () => {
const mockView = {
id: mockViewId,
name: 'Companies Sorted',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
visibility: ViewVisibility.WORKSPACE,
viewFilters: [],
viewFilterGroups: [],
viewSorts: [
{
id: 'sort-id',
fieldMetadataId: mockFieldMetadataId,
direction: ViewSortDirection.DESC,
},
],
};
viewService.findById.mockResolvedValue(mockView as any);
flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
flatFieldMetadataMaps: mockFlatFieldMetadataMaps,
} as any,
);
const result = await viewQueryParamsService.resolveViewToQueryParams(
mockViewId,
mockWorkspaceId,
);
expect(result.objectNameSingular).toBe('company');
expect(result.orderBy).toHaveLength(1);
expect(result.orderBy[0]).toHaveProperty('name');
});
it('should skip filters for deleted fields', async () => {
const deletedFieldId = 'deleted-field-id';
const mockFilterGroupId = 'filter-group-id';
const mockView = {
id: mockViewId,
name: 'View with deleted field filter',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
visibility: ViewVisibility.WORKSPACE,
viewFilters: [
{
id: 'filter-id',
fieldMetadataId: deletedFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'test',
viewFilterGroupId: mockFilterGroupId,
subFieldName: null,
},
],
viewFilterGroups: [
{
id: mockFilterGroupId,
parentViewFilterGroupId: null,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
},
],
viewSorts: [],
};
viewService.findById.mockResolvedValue(mockView as any);
flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
flatFieldMetadataMaps: mockFlatFieldMetadataMaps,
} as any,
);
const result = await viewQueryParamsService.resolveViewToQueryParams(
mockViewId,
mockWorkspaceId,
);
// Filter should be effectively empty because the field was deleted
expect(result.filter).toEqual({ and: [] });
});
});
});
@@ -0,0 +1,144 @@
import { Injectable } from '@nestjs/common';
import {
OrderByDirection,
RecordFilterGroupLogicalOperator,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
import {
computeRecordGqlOperationFilter,
isDefined,
type RecordFilter,
type RecordFilterGroup,
} from 'twenty-shared/utils';
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
export type ViewQueryParams = {
objectNameSingular: string;
filter: RecordGqlOperationFilter;
orderBy: ObjectRecordOrderBy;
viewName: string;
viewType: ViewType;
};
@Injectable()
export class ViewQueryParamsService {
constructor(
private readonly viewService: ViewService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async resolveViewToQueryParams(
viewId: string,
workspaceId: string,
currentWorkspaceMemberId?: string,
): Promise<ViewQueryParams> {
const view = await this.viewService.findById(viewId, workspaceId);
if (!view) {
throw new Error(`View with id ${viewId} not found`);
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const objectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: view.objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const recordFilters: RecordFilter[] = (view.viewFilters ?? [])
.map((viewFilter) => {
const field = flatFieldMetadataMaps.byId[viewFilter.fieldMetadataId];
if (!field) return null;
return {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
value: viewFilter.value ?? '',
type: field.type,
recordFilterGroupId: viewFilter.viewFilterGroupId,
operand: viewFilter.operand,
subFieldName: viewFilter.subFieldName,
} as RecordFilter;
})
.filter(isDefined);
const recordFilterGroups: RecordFilterGroup[] = (
view.viewFilterGroups ?? []
).map((group) => ({
id: group.id,
parentRecordFilterGroupId: group.parentViewFilterGroupId,
logicalOperator:
group.logicalOperator === ViewFilterGroupLogicalOperator.OR
? RecordFilterGroupLogicalOperator.OR
: RecordFilterGroupLogicalOperator.AND,
}));
const fields = recordFilters
.map((filter) => {
const field = flatFieldMetadataMaps.byId[filter.fieldMetadataId];
if (!field) return null;
return {
id: field.id,
name: field.name,
type: field.type,
label: field.label,
options: field.options?.map((opt) => ({
id: opt.id ?? '',
label: opt.label,
value: opt.value,
color: 'color' in opt ? opt.color : undefined,
position: opt.position,
})),
};
})
.filter(isDefined);
const filter = computeRecordGqlOperationFilter({
fields,
recordFilters,
recordFilterGroups,
filterValueDependencies: { currentWorkspaceMemberId },
});
const orderBy: ObjectRecordOrderBy = (view.viewSorts ?? [])
.map((sort) => {
const field = flatFieldMetadataMaps.byId[sort.fieldMetadataId];
if (!field) return null;
return {
[field.name]:
sort.direction === ViewSortDirection.DESC
? OrderByDirection.DescNullsLast
: OrderByDirection.AscNullsFirst,
};
})
.filter(isDefined);
return {
objectNameSingular: objectMetadata.nameSingular,
filter,
orderBy,
viewName: view.name,
viewType: view.type,
};
}
}