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
@@ -6,10 +6,62 @@ import {
} from '@/ai/components/RecordLink';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Fragment, lazy, Suspense, useMemo } from 'react';
import { lazy, Suspense } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { isDefined } from 'twenty-shared/utils';
const TextWithRecordLinks = ({ text }: { text: string }) => {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
RECORD_REFERENCE_REGEX.lastIndex = 0;
let match;
while ((match = RECORD_REFERENCE_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const parsed = parseRecordReference(match[0]);
if (isDefined(parsed)) {
parts.push(
<RecordLink
key={match.index}
objectNameSingular={parsed.objectNameSingular}
recordId={parsed.recordId}
displayName={parsed.displayName}
/>,
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return <>{parts}</>;
};
const processChildrenForRecordLinks = (
children: React.ReactNode,
): React.ReactNode => {
if (typeof children === 'string') {
return <TextWithRecordLinks text={children} />;
}
if (Array.isArray(children)) {
return children.map((child, index) => (
<span key={index}>{processChildrenForRecordLinks(child)}</span>
));
}
return children;
};
const MarkdownRenderer = lazy(async () => {
const [{ default: Markdown }, { default: remarkGfm }] = await Promise.all([
import('react-markdown'),
@@ -32,6 +84,10 @@ const MarkdownRenderer = lazy(async () => {
<table>{children}</table>
</TableScrollContainer>
),
p: ({ children }) => <p>{processChildrenForRecordLinks(children)}</p>,
li: ({ children }) => (
<li>{processChildrenForRecordLinks(children)}</li>
),
}}
>
{children}
@@ -102,87 +158,12 @@ const LoadingSkeleton = () => {
);
};
const useTextWithRecordLinks = (text: string) => {
return useMemo(() => {
const parts: Array<
| string
| { type: 'record'; props: ReturnType<typeof parseRecordReference> }
> = [];
let lastIndex = 0;
RECORD_REFERENCE_REGEX.lastIndex = 0;
let match;
while ((match = RECORD_REFERENCE_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const parsed = parseRecordReference(match[0]);
if (isDefined(parsed)) {
parts.push({ type: 'record', props: parsed });
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}, [text]);
};
export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
const parts = useTextWithRecordLinks(text);
// If there are no record references, render normally
const hasRecordReferences = parts.some(
(part) => typeof part === 'object' && part.type === 'record',
);
if (!hasRecordReferences) {
return (
<Suspense fallback={<LoadingSkeleton />}>
<MarkdownRenderer TableScrollContainer={StyledTableScrollContainer}>
{text}
</MarkdownRenderer>
</Suspense>
);
}
// Render with record links inline
return (
<Suspense fallback={<LoadingSkeleton />}>
{parts.map((part, index) => {
if (typeof part === 'string') {
return (
<MarkdownRenderer
key={index}
TableScrollContainer={StyledTableScrollContainer}
>
{part}
</MarkdownRenderer>
);
}
if (part.type === 'record' && isDefined(part.props)) {
return (
<Fragment key={index}>
<RecordLink
objectNameSingular={part.props.objectNameSingular}
recordId={part.props.recordId}
displayName={part.props.displayName}
/>
</Fragment>
);
}
return null;
})}
<MarkdownRenderer TableScrollContainer={StyledTableScrollContainer}>
{text}
</MarkdownRenderer>
</Suspense>
);
};
@@ -40,6 +40,27 @@ export const generateFindToolInputSchema = (
filterSchema;
});
// Create the base filter schema with field-level filters + logical operators
// This matches the RecordGqlOperationFilter format used by the frontend
const filterSchema: z.ZodTypeAny = z.lazy(() =>
z
.object({
...filterShape,
or: z
.array(filterSchema)
.optional()
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(filterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: filterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
})
.partial(),
);
return z.object({
loadingMessage: z
.string()
@@ -65,6 +86,17 @@ export const generateFindToolInputSchema = (
'Sort records by field(s). CRITICAL for "top N", "largest", "smallest" queries. Each item is an object with exactly ONE property: field name as key, sort direction as value. Example: [{"employees": "DescNullsLast"}] sorts employees descending. Use "DescNullsLast" for top/largest, "AscNullsFirst" for bottom/smallest.',
),
...filterShape,
or: z
.array(filterSchema)
.optional()
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(filterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: filterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
}),
});
};
@@ -4,4 +4,5 @@ export enum ToolCategory {
WORKFLOW = 'WORKFLOW',
METADATA = 'METADATA',
NATIVE_MODEL = 'NATIVE_MODEL',
VIEW = 'VIEW',
}
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
@Injectable()
export class ViewToolProvider implements ToolProvider {
readonly category = ToolCategory.VIEW;
constructor(
private readonly viewToolsFactory: ViewToolsFactory,
private readonly permissionsService: PermissionsService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const readTools = this.viewToolsFactory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
);
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.VIEWS,
);
if (hasViewPermission) {
const writeTools = this.viewToolsFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
);
return { ...readTools, ...writeTools };
}
return readTools;
}
}
@@ -15,14 +15,14 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm
export type ToolIndexEntry = {
name: string;
description: string;
category: 'database' | 'action' | 'workflow' | 'metadata';
category: 'database' | 'action' | 'workflow' | 'metadata' | 'view';
objectName?: string;
operation?: string;
};
export type ToolSearchOptions = {
limit?: number;
category?: 'database' | 'action' | 'workflow' | 'metadata';
category?: 'database' | 'action' | 'workflow' | 'metadata' | 'view';
};
export type ToolContext = {
@@ -173,6 +173,7 @@ export class ToolRegistryService {
WORKFLOW: 'workflow',
METADATA: 'metadata',
NATIVE_MODEL: 'action',
VIEW: 'view',
};
return Object.entries(tools).map(([name, tool]) => ({
@@ -6,6 +6,7 @@ import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/
import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider';
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider';
import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider';
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
@@ -14,6 +15,7 @@ import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { ToolProviderService } from './services/tool-provider.service';
@@ -37,6 +39,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
ObjectMetadataModule,
FieldMetadataModule,
PermissionsModule,
ViewModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
@@ -44,6 +47,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
ActionToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
ViewToolProvider,
WorkflowToolProvider,
{
provide: TOOL_PROVIDERS,
@@ -51,17 +55,20 @@ import { ToolRegistryService } from './services/tool-registry.service';
actionProvider: ActionToolProvider,
databaseProvider: DatabaseToolProvider,
metadataProvider: MetadataToolProvider,
viewProvider: ViewToolProvider,
workflowProvider: WorkflowToolProvider,
) => [
actionProvider,
databaseProvider,
metadataProvider,
viewProvider,
workflowProvider,
],
inject: [
ActionToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
ViewToolProvider,
WorkflowToolProvider,
],
},
@@ -0,0 +1,28 @@
import type { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
export const formatValidationErrors = (
error: WorkspaceMigrationBuilderExceptionV2,
): string => {
const report = error.failedWorkspaceMigrationBuildResult.report;
const errorMessages: string[] = [];
for (const [entityType, failures] of Object.entries(report)) {
if (Array.isArray(failures) && failures.length > 0) {
for (const failure of failures) {
if (failure.errors && Array.isArray(failure.errors)) {
for (const validationError of failure.errors) {
const message = validationError.message || validationError.code;
errorMessages.push(`[${entityType}] ${message}`);
}
}
}
}
}
if (errorMessages.length === 0) {
return error.message;
}
return `Validation errors:\n${errorMessages.join('\n')}`;
};
@@ -233,15 +233,17 @@ export class ChatExecutionService {
viewName: string;
filterDescriptions: string[];
}): string {
const { objectNameSingular, viewName, filterDescriptions } =
const { objectNameSingular, viewId, viewName, filterDescriptions } =
browsingContext;
let context = `The user is viewing a list of ${objectNameSingular} records in a view called "${viewName}".`;
let context = `The user is viewing a list of ${objectNameSingular} records in a view called "${viewName}" (viewId: ${viewId}).`;
if (filterDescriptions.length > 0) {
context += `\nFilters applied: ${filterDescriptions.join(', ')}`;
}
context += `\nUse get_view_query_parameters tool with this viewId to get the exact filter/sort parameters for querying records.`;
return context;
}
@@ -323,7 +325,13 @@ ${preloadedTools.length > 0 ? preloadedTools.map((t) => `- \`${t}\` ✓`).join('
### Tool Catalog by Category`);
const categoryOrder = ['database', 'action', 'workflow', 'metadata'];
const categoryOrder = [
'database',
'action',
'workflow',
'metadata',
'view',
];
for (const category of categoryOrder) {
const tools = toolsByCategory.get(category);
@@ -365,6 +373,8 @@ ${tools
return 'Workflow Tools (create/manage workflows)';
case 'metadata':
return 'Metadata Tools (schema management)';
case 'view':
return 'View Tools (query views)';
default:
return category;
}
@@ -4,6 +4,7 @@ import { type ToolSet } from 'ai';
import { FieldMetadataType } from 'twenty-shared/types';
import { z } from 'zod';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
@@ -139,33 +140,6 @@ const DeleteFieldMetadataInputSchema = z.object({
}),
});
const formatValidationErrors = (
error: WorkspaceMigrationBuilderExceptionV2,
): string => {
const report = error.failedWorkspaceMigrationBuildResult.report;
const errorMessages: string[] = [];
for (const [entityType, failures] of Object.entries(report)) {
if (Array.isArray(failures) && failures.length > 0) {
for (const failure of failures) {
if (failure.errors && Array.isArray(failure.errors)) {
for (const validationError of failure.errors) {
const message = validationError.message || validationError.code;
errorMessages.push(`[${entityType}] ${message}`);
}
}
}
}
}
if (errorMessages.length === 0) {
return error.message;
}
return `Validation errors:\n${errorMessages.join('\n')}`;
};
@Injectable()
export class FieldMetadataToolsFactory {
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
@@ -3,6 +3,7 @@ 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 { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
@@ -116,33 +117,6 @@ const DeleteObjectMetadataInputSchema = z.object({
}),
});
const formatValidationErrors = (
error: WorkspaceMigrationBuilderExceptionV2,
): string => {
const report = error.failedWorkspaceMigrationBuildResult.report;
const errorMessages: string[] = [];
for (const [entityType, failures] of Object.entries(report)) {
if (Array.isArray(failures) && failures.length > 0) {
for (const failure of failures) {
if (failure.errors && Array.isArray(failure.errors)) {
for (const validationError of failure.errors) {
const message = validationError.message || validationError.code;
errorMessages.push(`[${entityType}] ${message}`);
}
}
}
}
}
if (errorMessages.length === 0) {
return error.message;
}
return `Validation errors:\n${errorMessages.join('\n')}`;
};
@Injectable()
export class ObjectMetadataToolsFactory {
constructor(private readonly objectMetadataService: ObjectMetadataService) {}
@@ -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,
};
}
}
@@ -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;
}
},
},
};
}
}
@@ -16,7 +16,9 @@ import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.
import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewResolver } from 'src/engine/metadata-modules/view/resolvers/view.resolver';
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';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
@@ -39,7 +41,17 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
controllers: [ViewController],
providers: [ViewService, ViewResolver],
exports: [ViewService, TypeOrmModule.forFeature([ViewEntity])],
providers: [
ViewService,
ViewResolver,
ViewQueryParamsService,
ViewToolsFactory,
],
exports: [
ViewService,
ViewQueryParamsService,
ViewToolsFactory,
TypeOrmModule.forFeature([ViewEntity]),
],
})
export class ViewModule {}