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:
+254
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
+144
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user