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:
+431
@@ -0,0 +1,431 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { OrderByDirection } from 'twenty-shared/types';
|
||||
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
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';
|
||||
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
|
||||
|
||||
describe('ViewToolsFactory', () => {
|
||||
let viewToolsFactory: ViewToolsFactory;
|
||||
let viewService: jest.Mocked<ViewService>;
|
||||
let viewQueryParamsService: jest.Mocked<ViewQueryParamsService>;
|
||||
let _flatEntityMapsCacheService: jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>;
|
||||
|
||||
const mockWorkspaceId = 'workspace-id';
|
||||
const mockUserWorkspaceId = 'user-workspace-id';
|
||||
const mockViewId = 'view-id';
|
||||
const mockObjectMetadataId = 'object-metadata-id';
|
||||
const mockObjectNameSingular = 'company';
|
||||
|
||||
const mockView = {
|
||||
id: mockViewId,
|
||||
name: 'All Companies',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: ViewType.TABLE,
|
||||
icon: 'IconBuilding',
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
position: 0,
|
||||
createdByUserWorkspaceId: mockUserWorkspaceId,
|
||||
};
|
||||
|
||||
const mockFlatObjectMetadataMaps = {
|
||||
byId: {
|
||||
[mockObjectMetadataId]: {
|
||||
id: mockObjectMetadataId,
|
||||
nameSingular: mockObjectNameSingular,
|
||||
namePlural: 'companies',
|
||||
labelSingular: 'Company',
|
||||
labelPlural: 'Companies',
|
||||
},
|
||||
},
|
||||
idByUniversalIdentifier: {},
|
||||
universalIdentifiersByApplicationId: {},
|
||||
};
|
||||
|
||||
const callExecute = async (tool: any, input: any) => {
|
||||
return tool.execute(input);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ViewToolsFactory,
|
||||
{
|
||||
provide: ViewService,
|
||||
useValue: {
|
||||
findByWorkspaceId: jest.fn(),
|
||||
findByObjectMetadataId: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
createOne: jest.fn(),
|
||||
updateOne: jest.fn(),
|
||||
deleteOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ViewQueryParamsService,
|
||||
useValue: {
|
||||
resolveViewToQueryParams: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
useValue: {
|
||||
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
|
||||
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
viewToolsFactory = module.get<ViewToolsFactory>(ViewToolsFactory);
|
||||
viewService = module.get(ViewService);
|
||||
viewQueryParamsService = module.get(ViewQueryParamsService);
|
||||
_flatEntityMapsCacheService = module.get(
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(viewToolsFactory).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateReadTools', () => {
|
||||
it('should generate get-views and get-view-query-parameters tools', () => {
|
||||
const tools = viewToolsFactory.generateReadTools(mockWorkspaceId);
|
||||
|
||||
expect(tools).toHaveProperty('get_views');
|
||||
expect(tools).toHaveProperty('get_view_query_parameters');
|
||||
expect(tools['get_views']).toHaveProperty('description');
|
||||
expect(tools['get_views']).toHaveProperty('inputSchema');
|
||||
expect(tools['get_views']).toHaveProperty('execute');
|
||||
expect(tools['get_view_query_parameters']).toHaveProperty('description');
|
||||
expect(tools['get_view_query_parameters']).toHaveProperty('inputSchema');
|
||||
expect(tools['get_view_query_parameters']).toHaveProperty('execute');
|
||||
});
|
||||
|
||||
describe('get_views tool', () => {
|
||||
it('should return all views when no objectNameSingular filter', async () => {
|
||||
const mockViews = [mockView];
|
||||
|
||||
viewService.findByWorkspaceId.mockResolvedValue(mockViews as any);
|
||||
|
||||
const tools = viewToolsFactory.generateReadTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['get_views'], {
|
||||
input: { limit: 50 },
|
||||
});
|
||||
|
||||
expect(viewService.findByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
id: mockViewId,
|
||||
name: 'All Companies',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: ViewType.TABLE,
|
||||
icon: 'IconBuilding',
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
position: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter views by objectNameSingular', async () => {
|
||||
const mockViews = [mockView];
|
||||
|
||||
viewService.findByObjectMetadataId.mockResolvedValue(mockViews as any);
|
||||
|
||||
const tools = viewToolsFactory.generateReadTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['get_views'], {
|
||||
input: { objectNameSingular: mockObjectNameSingular, limit: 50 },
|
||||
});
|
||||
|
||||
expect(viewService.findByObjectMetadataId).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
mockObjectMetadataId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should respect limit parameter', async () => {
|
||||
const mockViews = [
|
||||
{ ...mockView, id: 'view-1' },
|
||||
{ ...mockView, id: 'view-2' },
|
||||
{ ...mockView, id: 'view-3' },
|
||||
];
|
||||
|
||||
viewService.findByWorkspaceId.mockResolvedValue(mockViews as any);
|
||||
|
||||
const tools = viewToolsFactory.generateReadTools(mockWorkspaceId);
|
||||
|
||||
const result = await callExecute(tools['get_views'], {
|
||||
input: { limit: 2 },
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get-view-query-parameters tool', () => {
|
||||
it('should return query parameters for a view', async () => {
|
||||
const mockQueryParams = {
|
||||
objectNameSingular: 'company',
|
||||
filter: { name: { ilike: '%Acme%' } },
|
||||
orderBy: [{ name: OrderByDirection.AscNullsFirst }],
|
||||
viewName: 'All Companies',
|
||||
viewType: ViewType.TABLE,
|
||||
};
|
||||
|
||||
viewQueryParamsService.resolveViewToQueryParams.mockResolvedValue(
|
||||
mockQueryParams,
|
||||
);
|
||||
|
||||
const tools = viewToolsFactory.generateReadTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
'workspace-member-id',
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['get_view_query_parameters'], {
|
||||
input: { viewId: mockViewId },
|
||||
});
|
||||
|
||||
expect(
|
||||
viewQueryParamsService.resolveViewToQueryParams,
|
||||
).toHaveBeenCalledWith(
|
||||
mockViewId,
|
||||
mockWorkspaceId,
|
||||
'workspace-member-id',
|
||||
);
|
||||
expect(result).toEqual(mockQueryParams);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateWriteTools', () => {
|
||||
it('should generate create-view, update-view, and delete-view tools', () => {
|
||||
const tools = viewToolsFactory.generateWriteTools(mockWorkspaceId);
|
||||
|
||||
expect(tools).toHaveProperty('create_view');
|
||||
expect(tools).toHaveProperty('update_view');
|
||||
expect(tools).toHaveProperty('delete_view');
|
||||
});
|
||||
|
||||
describe('create_view tool', () => {
|
||||
it('should create a new view', 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,
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['create_view'], {
|
||||
input: {
|
||||
name: 'New View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
icon: 'IconTable',
|
||||
},
|
||||
});
|
||||
|
||||
expect(viewService.createOne).toHaveBeenCalledWith({
|
||||
createViewInput: {
|
||||
name: 'New View',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
icon: 'IconTable',
|
||||
type: ViewType.TABLE,
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
},
|
||||
workspaceId: mockWorkspaceId,
|
||||
createdByUserWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 'new-view-id',
|
||||
name: 'New View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
type: ViewType.TABLE,
|
||||
icon: 'IconTable',
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update-view tool', () => {
|
||||
it('should update a workspace view', async () => {
|
||||
const existingView = {
|
||||
...mockView,
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
};
|
||||
const updatedView = {
|
||||
...existingView,
|
||||
name: 'Updated Name',
|
||||
};
|
||||
|
||||
viewService.findById.mockResolvedValue(existingView as any);
|
||||
viewService.updateOne.mockResolvedValue(updatedView as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['update_view'], {
|
||||
input: {
|
||||
id: mockViewId,
|
||||
name: 'Updated Name',
|
||||
},
|
||||
});
|
||||
|
||||
expect(viewService.updateOne).toHaveBeenCalled();
|
||||
expect(result.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should allow updating own unlisted view', async () => {
|
||||
const existingView = {
|
||||
...mockView,
|
||||
visibility: ViewVisibility.UNLISTED,
|
||||
createdByUserWorkspaceId: mockUserWorkspaceId,
|
||||
};
|
||||
const updatedView = {
|
||||
...existingView,
|
||||
name: 'Updated Name',
|
||||
};
|
||||
|
||||
viewService.findById.mockResolvedValue(existingView as any);
|
||||
viewService.updateOne.mockResolvedValue(updatedView as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['update_view'], {
|
||||
input: {
|
||||
id: mockViewId,
|
||||
name: 'Updated Name',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should reject updating another users unlisted view', async () => {
|
||||
const existingView = {
|
||||
...mockView,
|
||||
visibility: ViewVisibility.UNLISTED,
|
||||
createdByUserWorkspaceId: 'other-user-workspace-id',
|
||||
};
|
||||
|
||||
viewService.findById.mockResolvedValue(existingView as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await expect(
|
||||
callExecute(tools['update_view'], {
|
||||
input: {
|
||||
id: mockViewId,
|
||||
name: 'Updated Name',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('You can only update your own unlisted views');
|
||||
});
|
||||
|
||||
it('should throw error when view not found', async () => {
|
||||
viewService.findById.mockResolvedValue(null);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(mockWorkspaceId);
|
||||
|
||||
await expect(
|
||||
callExecute(tools['update_view'], {
|
||||
input: {
|
||||
id: 'non-existent-id',
|
||||
name: 'Updated Name',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('View with id non-existent-id not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete-view tool', () => {
|
||||
it('should delete a workspace view', async () => {
|
||||
const existingView = {
|
||||
...mockView,
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
};
|
||||
const deletedView = {
|
||||
id: mockViewId,
|
||||
name: 'All Companies',
|
||||
};
|
||||
|
||||
viewService.findById.mockResolvedValue(existingView as any);
|
||||
viewService.deleteOne.mockResolvedValue(deletedView as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['delete_view'], {
|
||||
input: { id: mockViewId },
|
||||
});
|
||||
|
||||
expect(viewService.deleteOne).toHaveBeenCalledWith({
|
||||
deleteViewInput: { id: mockViewId },
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: mockViewId,
|
||||
name: 'All Companies',
|
||||
deleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject deleting another users unlisted view', async () => {
|
||||
const existingView = {
|
||||
...mockView,
|
||||
visibility: ViewVisibility.UNLISTED,
|
||||
createdByUserWorkspaceId: 'other-user-workspace-id',
|
||||
};
|
||||
|
||||
viewService.findById.mockResolvedValue(existingView as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await expect(
|
||||
callExecute(tools['delete_view'], {
|
||||
input: { id: mockViewId },
|
||||
}),
|
||||
).rejects.toThrow('You can only delete your own unlisted views');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
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 { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
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';
|
||||
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
|
||||
|
||||
const GetViewsInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
objectNameSingular: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Filter views by object name (e.g., "task", "person", "company"). If omitted, returns all views.',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(50)
|
||||
.describe('Maximum views to return.'),
|
||||
}),
|
||||
});
|
||||
|
||||
const GetViewQueryParamsInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
viewId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('ID of the view to get query parameters for.'),
|
||||
}),
|
||||
});
|
||||
|
||||
const CreateViewInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
name: z.string().describe('View name'),
|
||||
objectNameSingular: z
|
||||
.string()
|
||||
.describe(
|
||||
'Object name this view is for (e.g., "task", "person", "company")',
|
||||
),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('IconList')
|
||||
.describe('Icon identifier (e.g., "IconList", "IconCheckbox")'),
|
||||
type: z
|
||||
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
|
||||
.optional()
|
||||
.default(ViewType.TABLE)
|
||||
.describe('View type'),
|
||||
visibility: z
|
||||
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
|
||||
.optional()
|
||||
.default(ViewVisibility.WORKSPACE)
|
||||
.describe('View visibility'),
|
||||
}),
|
||||
});
|
||||
|
||||
const UpdateViewInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('View ID to update'),
|
||||
name: z.string().optional().describe('New view name'),
|
||||
icon: z.string().optional().describe('New icon identifier'),
|
||||
}),
|
||||
});
|
||||
|
||||
const DeleteViewInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('View ID to delete'),
|
||||
}),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class ViewToolsFactory {
|
||||
constructor(
|
||||
private readonly viewService: ViewService,
|
||||
private readonly viewQueryParamsService: ViewQueryParamsService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
) {}
|
||||
|
||||
private async resolveObjectMetadataId(
|
||||
workspaceId: string,
|
||||
objectNameSingular: string,
|
||||
): Promise<string> {
|
||||
const { flatObjectMetadataMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatObjectMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const objectMetadata = Object.values(flatObjectMetadataMaps.byId).find(
|
||||
(obj) => obj?.nameSingular === objectNameSingular,
|
||||
);
|
||||
|
||||
if (!objectMetadata) {
|
||||
throw new Error(
|
||||
`Object "${objectNameSingular}" not found. Use get_object_metadata to list available objects.`,
|
||||
);
|
||||
}
|
||||
|
||||
return objectMetadata.id;
|
||||
}
|
||||
|
||||
generateReadTools(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
currentWorkspaceMemberId?: string,
|
||||
): ToolSet {
|
||||
return {
|
||||
get_views: {
|
||||
description:
|
||||
'List views in the workspace. Views define how records are displayed, filtered, and sorted.',
|
||||
inputSchema: GetViewsInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: { objectNameSingular?: string; limit?: number };
|
||||
}) => {
|
||||
let views;
|
||||
|
||||
if (parameters.input.objectNameSingular) {
|
||||
const objectMetadataId = await this.resolveObjectMetadataId(
|
||||
workspaceId,
|
||||
parameters.input.objectNameSingular,
|
||||
);
|
||||
|
||||
views = await this.viewService.findByObjectMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
} else {
|
||||
views = await this.viewService.findByWorkspaceId(
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
const limitedViews = views.slice(0, parameters.input.limit ?? 50);
|
||||
|
||||
return limitedViews.map((view) => ({
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
objectMetadataId: view.objectMetadataId,
|
||||
type: view.type,
|
||||
icon: view.icon,
|
||||
visibility: view.visibility,
|
||||
position: view.position,
|
||||
}));
|
||||
},
|
||||
},
|
||||
get_view_query_parameters: {
|
||||
description:
|
||||
'Get filter and sort parameters from a view. Use these parameters with find_* tools to query records matching the view.',
|
||||
inputSchema: GetViewQueryParamsInputSchema,
|
||||
execute: async (parameters: { input: { viewId: string } }) => {
|
||||
return this.viewQueryParamsService.resolveViewToQueryParams(
|
||||
parameters.input.viewId,
|
||||
workspaceId,
|
||||
currentWorkspaceMemberId,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
generateWriteTools(workspaceId: string, userWorkspaceId?: string): ToolSet {
|
||||
return {
|
||||
create_view: {
|
||||
description:
|
||||
'Create a new view for an object. Views define how records are displayed.',
|
||||
inputSchema: CreateViewInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
name: string;
|
||||
objectNameSingular: string;
|
||||
icon?: string;
|
||||
type?: ViewType;
|
||||
visibility?: ViewVisibility;
|
||||
};
|
||||
}) => {
|
||||
try {
|
||||
const objectMetadataId = await this.resolveObjectMetadataId(
|
||||
workspaceId,
|
||||
parameters.input.objectNameSingular,
|
||||
);
|
||||
|
||||
const view = await this.viewService.createOne({
|
||||
createViewInput: {
|
||||
name: parameters.input.name,
|
||||
objectMetadataId,
|
||||
icon: parameters.input.icon ?? 'IconList',
|
||||
type: parameters.input.type ?? ViewType.TABLE,
|
||||
visibility:
|
||||
parameters.input.visibility ?? ViewVisibility.WORKSPACE,
|
||||
},
|
||||
workspaceId,
|
||||
createdByUserWorkspaceId: userWorkspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
objectNameSingular: parameters.input.objectNameSingular,
|
||||
type: view.type,
|
||||
icon: view.icon,
|
||||
visibility: view.visibility,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderExceptionV2) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
update_view: {
|
||||
description:
|
||||
'Update an existing view. You can change the name and icon.',
|
||||
inputSchema: UpdateViewInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
id: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
};
|
||||
}) => {
|
||||
try {
|
||||
const existingView = await this.viewService.findById(
|
||||
parameters.input.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!existingView) {
|
||||
throw new Error(`View with id ${parameters.input.id} not found`);
|
||||
}
|
||||
|
||||
if (
|
||||
existingView.visibility === ViewVisibility.UNLISTED &&
|
||||
existingView.createdByUserWorkspaceId !== userWorkspaceId
|
||||
) {
|
||||
throw new Error('You can only update your own unlisted views');
|
||||
}
|
||||
|
||||
const view = await this.viewService.updateOne({
|
||||
updateViewInput: {
|
||||
id: parameters.input.id,
|
||||
name: parameters.input.name,
|
||||
icon: parameters.input.icon,
|
||||
},
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
objectMetadataId: view.objectMetadataId,
|
||||
type: view.type,
|
||||
icon: view.icon,
|
||||
visibility: view.visibility,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderExceptionV2) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
delete_view: {
|
||||
description: 'Delete a view by its ID.',
|
||||
inputSchema: DeleteViewInputSchema,
|
||||
execute: async (parameters: { input: { id: string } }) => {
|
||||
try {
|
||||
const existingView = await this.viewService.findById(
|
||||
parameters.input.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!existingView) {
|
||||
throw new Error(`View with id ${parameters.input.id} not found`);
|
||||
}
|
||||
|
||||
if (
|
||||
existingView.visibility === ViewVisibility.UNLISTED &&
|
||||
existingView.createdByUserWorkspaceId !== userWorkspaceId
|
||||
) {
|
||||
throw new Error('You can only delete your own unlisted views');
|
||||
}
|
||||
|
||||
const view = await this.viewService.deleteOne({
|
||||
deleteViewInput: { id: parameters.input.id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
deleted: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderExceptionV2) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user