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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+32
@@ -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'),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
+1
@@ -4,4 +4,5 @@ export enum ToolCategory {
|
||||
WORKFLOW = 'WORKFLOW',
|
||||
METADATA = 'METADATA',
|
||||
NATIVE_MODEL = 'NATIVE_MODEL',
|
||||
VIEW = 'VIEW',
|
||||
}
|
||||
|
||||
+55
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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,
|
||||
],
|
||||
},
|
||||
|
||||
+28
@@ -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')}`;
|
||||
};
|
||||
+13
-3
@@ -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;
|
||||
}
|
||||
|
||||
+1
-27
@@ -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) {}
|
||||
|
||||
+1
-27
@@ -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) {}
|
||||
|
||||
+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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user