Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa4cec67a | ||
|
|
e01beca3fd | ||
|
|
401ce9203a | ||
|
|
d02b5b4da4 | ||
|
|
ac0e3c226d | ||
|
|
c6588694a9 | ||
|
|
35bbb917bc |
@@ -65,10 +65,33 @@ export const useAgentChat = (
|
||||
agentChatDraftsByThreadIdState,
|
||||
);
|
||||
|
||||
const retryFetchWithRenewedToken = async (
|
||||
const getCurrentAccessToken = () => {
|
||||
const currentTokenPair = store.get(tokenPairState.atom);
|
||||
|
||||
return currentTokenPair?.accessOrWorkspaceAgnosticToken?.token;
|
||||
};
|
||||
|
||||
const retryFetchWithCurrentOrRenewedToken = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
failedAccessToken?: string,
|
||||
) => {
|
||||
const currentAccessToken = getCurrentAccessToken();
|
||||
|
||||
if (
|
||||
isDefined(currentAccessToken) &&
|
||||
currentAccessToken !== failedAccessToken
|
||||
) {
|
||||
const updatedHeaders = new Headers(init?.headers ?? {});
|
||||
|
||||
updatedHeaders.set('Authorization', `Bearer ${currentAccessToken}`);
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: updatedHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
const tokenPair = getTokenPair();
|
||||
|
||||
if (!isDefined(tokenPair)) {
|
||||
@@ -82,7 +105,6 @@ export const useAgentChat = (
|
||||
);
|
||||
|
||||
if (!isDefined(renewedTokens)) {
|
||||
setTokenPair(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -90,7 +112,6 @@ export const useAgentChat = (
|
||||
renewedTokens.accessOrWorkspaceAgnosticToken?.token;
|
||||
|
||||
if (!isDefined(renewedAccessToken)) {
|
||||
setTokenPair(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -98,6 +119,7 @@ export const useAgentChat = (
|
||||
setTokenPair(renewedTokens);
|
||||
|
||||
const updatedHeaders = new Headers(init?.headers ?? {});
|
||||
|
||||
updatedHeaders.set('Authorization', `Bearer ${renewedAccessToken}`);
|
||||
|
||||
return fetch(input, {
|
||||
@@ -105,7 +127,6 @@ export const useAgentChat = (
|
||||
headers: updatedHeaders,
|
||||
});
|
||||
} catch {
|
||||
setTokenPair(null);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -113,14 +134,26 @@ export const useAgentChat = (
|
||||
const { sendMessage, messages, status, error, regenerate, stop } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${REST_API_BASE_URL}/agent-chat/stream`,
|
||||
headers: () => ({
|
||||
Authorization: `Bearer ${getTokenPair()?.accessOrWorkspaceAgnosticToken.token}`,
|
||||
}),
|
||||
headers: () => {
|
||||
const token = getCurrentAccessToken();
|
||||
|
||||
return {
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
};
|
||||
},
|
||||
fetch: async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
|
||||
if (response.status === 401) {
|
||||
const retriedResponse = await retryFetchWithRenewedToken(input, init);
|
||||
const failedAccessToken = new Headers(init?.headers ?? {})
|
||||
.get('Authorization')
|
||||
?.replace('Bearer ', '');
|
||||
|
||||
const retriedResponse = await retryFetchWithCurrentOrRenewedToken(
|
||||
input,
|
||||
init,
|
||||
failedAccessToken,
|
||||
);
|
||||
|
||||
return retriedResponse ?? response;
|
||||
}
|
||||
|
||||
+41
-19
@@ -16,6 +16,8 @@ import {
|
||||
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { ViewFilterToolsFactory } from 'src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory';
|
||||
import { ViewSortToolsFactory } from 'src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory';
|
||||
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
|
||||
|
||||
@Injectable()
|
||||
@@ -24,23 +26,31 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
|
||||
|
||||
constructor(
|
||||
private readonly viewToolsFactory: ViewToolsFactory,
|
||||
private readonly viewFilterToolsFactory: ViewFilterToolsFactory,
|
||||
private readonly viewSortToolsFactory: ViewSortToolsFactory,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly toolExecutorService: ToolExecutorService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const factory = this.viewToolsFactory;
|
||||
const viewFactory = this.viewToolsFactory;
|
||||
const filterFactory = this.viewFilterToolsFactory;
|
||||
const sortFactory = this.viewSortToolsFactory;
|
||||
|
||||
this.toolExecutorService.registerCategoryGenerator(
|
||||
ToolCategory.VIEW,
|
||||
async (context) => {
|
||||
const workspaceMemberId = context.actorContext?.workspaceMemberId;
|
||||
|
||||
const readTools = factory.generateReadTools(
|
||||
context.workspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
workspaceMemberId ?? undefined,
|
||||
);
|
||||
const readTools = {
|
||||
...viewFactory.generateReadTools(
|
||||
context.workspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
workspaceMemberId ?? undefined,
|
||||
),
|
||||
...filterFactory.generateReadTools(context.workspaceId),
|
||||
...sortFactory.generateReadTools(context.workspaceId),
|
||||
};
|
||||
|
||||
const hasViewPermission =
|
||||
await this.permissionsService.checkRolesPermissions(
|
||||
@@ -50,10 +60,14 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
|
||||
);
|
||||
|
||||
if (hasViewPermission) {
|
||||
const writeTools = factory.generateWriteTools(
|
||||
context.workspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
);
|
||||
const writeTools = {
|
||||
...viewFactory.generateWriteTools(
|
||||
context.workspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
),
|
||||
...filterFactory.generateWriteTools(context.workspaceId),
|
||||
...sortFactory.generateWriteTools(context.workspaceId),
|
||||
};
|
||||
|
||||
return { ...readTools, ...writeTools };
|
||||
}
|
||||
@@ -76,11 +90,15 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
|
||||
includeSchemas: options?.includeSchemas ?? true,
|
||||
};
|
||||
|
||||
const readTools = this.viewToolsFactory.generateReadTools(
|
||||
context.workspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
workspaceMemberId ?? undefined,
|
||||
);
|
||||
const readTools = {
|
||||
...this.viewToolsFactory.generateReadTools(
|
||||
context.workspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
workspaceMemberId ?? undefined,
|
||||
),
|
||||
...this.viewFilterToolsFactory.generateReadTools(context.workspaceId),
|
||||
...this.viewSortToolsFactory.generateReadTools(context.workspaceId),
|
||||
};
|
||||
|
||||
const hasViewPermission =
|
||||
await this.permissionsService.checkRolesPermissions(
|
||||
@@ -90,10 +108,14 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
|
||||
);
|
||||
|
||||
if (hasViewPermission) {
|
||||
const writeTools = this.viewToolsFactory.generateWriteTools(
|
||||
context.workspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
);
|
||||
const writeTools = {
|
||||
...this.viewToolsFactory.generateWriteTools(
|
||||
context.workspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
),
|
||||
...this.viewFilterToolsFactory.generateWriteTools(context.workspaceId),
|
||||
...this.viewSortToolsFactory.generateWriteTools(context.workspaceId),
|
||||
};
|
||||
|
||||
return toolSetToDescriptors(
|
||||
{ ...readTools, ...writeTools },
|
||||
|
||||
@@ -25,6 +25,8 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
|
||||
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
|
||||
import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module';
|
||||
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@@ -48,6 +50,8 @@ import { ToolRegistryService } from './services/tool-registry.service';
|
||||
PermissionsModule,
|
||||
ViewModule,
|
||||
ViewFieldModule,
|
||||
ViewFilterModule,
|
||||
ViewSortModule,
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
LogicFunctionModule,
|
||||
|
||||
+55
-40
@@ -19,6 +19,7 @@ import {
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
|
||||
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
|
||||
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
@@ -182,66 +183,80 @@ export class NavigateAppTool implements Tool {
|
||||
},
|
||||
);
|
||||
|
||||
const availableObjectNames = navigationMenuItems
|
||||
.map((navigationMenuItem) => {
|
||||
if (isDefined(navigationMenuItem.viewId)) {
|
||||
const correspondingViewUniversalIdentifier =
|
||||
flatViewMaps.universalIdentifierById[navigationMenuItem.viewId];
|
||||
type NavigatableObject = {
|
||||
nameSingular: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
};
|
||||
|
||||
if (isDefined(correspondingViewUniversalIdentifier)) {
|
||||
const correspondingView =
|
||||
flatViewMaps.byUniversalIdentifier[
|
||||
correspondingViewUniversalIdentifier
|
||||
];
|
||||
const navigatableObjects = navigationMenuItems
|
||||
.map<NavigatableObject | null>((navigationMenuItem) => {
|
||||
const objectMetadataId =
|
||||
navigationMenuItem.type === NavigationMenuItemType.OBJECT
|
||||
? navigationMenuItem.targetObjectMetadataId
|
||||
: navigationMenuItem.type === NavigationMenuItemType.VIEW &&
|
||||
isDefined(navigationMenuItem.viewId)
|
||||
? flatViewMaps.byUniversalIdentifier[
|
||||
flatViewMaps.universalIdentifierById[
|
||||
navigationMenuItem.viewId
|
||||
] ?? ''
|
||||
]?.objectMetadataId
|
||||
: undefined;
|
||||
|
||||
if (isDefined(correspondingView)) {
|
||||
const correspondingObjectMetadataUniversalIdentifier =
|
||||
flatObjectMetadataMaps.universalIdentifierById[
|
||||
correspondingView.objectMetadataId
|
||||
];
|
||||
|
||||
if (isDefined(correspondingObjectMetadataUniversalIdentifier)) {
|
||||
const correspondingObjectMetadata =
|
||||
flatObjectMetadataMaps.byUniversalIdentifier[
|
||||
correspondingObjectMetadataUniversalIdentifier
|
||||
];
|
||||
|
||||
if (isDefined(correspondingObjectMetadata)) {
|
||||
const correspondingObjectNameSingular =
|
||||
correspondingObjectMetadata.nameSingular;
|
||||
|
||||
return correspondingObjectNameSingular;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isDefined(objectMetadataId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
const objectMetadataUniversalIdentifier =
|
||||
flatObjectMetadataMaps.universalIdentifierById[objectMetadataId];
|
||||
|
||||
if (!isDefined(objectMetadataUniversalIdentifier)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectMetadata =
|
||||
flatObjectMetadataMaps.byUniversalIdentifier[
|
||||
objectMetadataUniversalIdentifier
|
||||
];
|
||||
|
||||
if (!isDefined(objectMetadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
nameSingular: objectMetadata.nameSingular,
|
||||
labelSingular: objectMetadata.labelSingular,
|
||||
labelPlural: objectMetadata.labelPlural,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
const fuse = new Fuse(availableObjectNames, {
|
||||
threshold: 0.6,
|
||||
const fuse = new Fuse(navigatableObjects, {
|
||||
keys: ['nameSingular', 'labelSingular', 'labelPlural'],
|
||||
threshold: 0.4,
|
||||
});
|
||||
|
||||
const results = fuse.search(objectNameSingular.replace(/\s/g, ''));
|
||||
const firstMatchingNavigationItemLabel = results[0]?.item;
|
||||
const results = fuse.search(objectNameSingular);
|
||||
const matchingObject = results[0]?.item;
|
||||
|
||||
if (!isDefined(matchingObject)) {
|
||||
const availableLabels = navigatableObjects
|
||||
.map((object) => object.labelPlural)
|
||||
.join(', ');
|
||||
|
||||
if (!isDefined(firstMatchingNavigationItemLabel)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Object "${objectNameSingular}" not found`,
|
||||
error: `No object with singular name "${objectNameSingular}" was found in this workspace. Available objects: ${availableObjectNames}`,
|
||||
error: `No object matching "${objectNameSingular}" was found in this workspace. Available objects: ${availableLabels}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Navigating to ${firstMatchingNavigationItemLabel} default view`,
|
||||
message: `Navigating to ${matchingObject.labelPlural} default view`,
|
||||
result: {
|
||||
action: 'navigateToObject',
|
||||
objectNameSingular: firstMatchingNavigationItemLabel,
|
||||
objectNameSingular: matchingObject.nameSingular,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -326,7 +326,7 @@ ${hasWebSearch ? '3' : '2'}. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NA
|
||||
case ToolCategory.METADATA:
|
||||
return 'Metadata Tools (schema management)';
|
||||
case ToolCategory.VIEW:
|
||||
return 'View Tools (query views)';
|
||||
return 'View Tools (manage views, filters, and sorts)';
|
||||
case ToolCategory.DASHBOARD:
|
||||
return 'Dashboard Tools (create/manage dashboards)';
|
||||
case ToolCategory.LOGIC_FUNCTION:
|
||||
|
||||
+7
@@ -188,6 +188,13 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
]);
|
||||
}
|
||||
|
||||
if (isDefined(updateObjectInput.update.isActive)) {
|
||||
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatNavigationMenuItemMaps'],
|
||||
});
|
||||
}
|
||||
|
||||
return updatedFlatObjectMetadata;
|
||||
}
|
||||
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
|
||||
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
|
||||
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
|
||||
const VIEW_FILTER_OPERAND_OPTIONS = Object.values(ViewFilterOperand);
|
||||
|
||||
const GetViewFiltersInputSchema = z.object({
|
||||
viewId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe(
|
||||
'ID of the view to list filters for. Obtain this from get_views.',
|
||||
),
|
||||
});
|
||||
|
||||
const CreateViewFilterInputSchema = z.object({
|
||||
viewId: z.string().uuid().describe('ID of the view to add the filter to'),
|
||||
fieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe(
|
||||
'ID of the field to filter on. Use list_object_metadata_items to find field IDs.',
|
||||
),
|
||||
operand: z
|
||||
.enum(VIEW_FILTER_OPERAND_OPTIONS)
|
||||
.describe(
|
||||
'Filter operator. Valid operators per field type — TEXT/EMAILS/FULL_NAME: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY. NUMBER/NUMERIC: IS, IS_NOT, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS_EMPTY, IS_NOT_EMPTY. CURRENCY: GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS_EMPTY, IS_NOT_EMPTY. DATE/DATE_TIME: IS, IS_RELATIVE, IS_IN_PAST, IS_IN_FUTURE, IS_TODAY, IS_BEFORE, IS_AFTER, IS_EMPTY, IS_NOT_EMPTY. SELECT: IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY. MULTI_SELECT/ARRAY: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY. RELATION: IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY. BOOLEAN: IS.',
|
||||
),
|
||||
value: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.string()),
|
||||
z.record(z.string(), z.unknown()),
|
||||
])
|
||||
.describe(
|
||||
'Filter value. Format depends on operand and field type: string for TEXT/SELECT, number for NUMBER/CURRENCY, array of option values for MULTI_SELECT IS/IS_NOT, empty string "" for IS_EMPTY/IS_NOT_EMPTY operators.',
|
||||
),
|
||||
subFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Required for composite fields — e.g. "amountMicros" for CURRENCY, "addressCity" for ADDRESS, "firstName" or "lastName" for FULL_NAME.',
|
||||
),
|
||||
});
|
||||
|
||||
const CreateManyViewFiltersInputSchema = z.object({
|
||||
filters: z
|
||||
.array(CreateViewFilterInputSchema)
|
||||
.min(1)
|
||||
.max(20)
|
||||
.describe('Array of filters to create (1-20 items)'),
|
||||
});
|
||||
|
||||
const UpdateViewFilterInputSchema = z.object({
|
||||
id: z.string().uuid().describe('ID of the view filter to update'),
|
||||
operand: z
|
||||
.enum(VIEW_FILTER_OPERAND_OPTIONS)
|
||||
.optional()
|
||||
.describe('New filter operator'),
|
||||
value: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.string()),
|
||||
z.record(z.string(), z.unknown()),
|
||||
])
|
||||
.optional()
|
||||
.describe('New filter value'),
|
||||
subFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New sub-field name for composite fields'),
|
||||
});
|
||||
|
||||
const DeleteViewFilterInputSchema = z.object({
|
||||
id: z.string().uuid().describe('ID of the view filter to delete'),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class ViewFilterToolsFactory {
|
||||
constructor(private readonly viewFilterService: ViewFilterService) {}
|
||||
|
||||
generateReadTools(workspaceId: string): ToolSet {
|
||||
return {
|
||||
get_view_filters: {
|
||||
description:
|
||||
'List all filters applied to a view. Each filter defines a condition that records must match to appear in the view.',
|
||||
inputSchema: GetViewFiltersInputSchema,
|
||||
execute: async (parameters: { viewId: string }) => {
|
||||
const filters = await this.viewFilterService.findByViewId(
|
||||
workspaceId,
|
||||
parameters.viewId,
|
||||
);
|
||||
|
||||
return filters.map((filter) => ({
|
||||
id: filter.id,
|
||||
viewId: filter.viewId,
|
||||
fieldMetadataId: filter.fieldMetadataId,
|
||||
operand: filter.operand,
|
||||
value: filter.value,
|
||||
subFieldName: filter.subFieldName,
|
||||
positionInViewFilterGroup: filter.positionInViewFilterGroup,
|
||||
}));
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
generateWriteTools(workspaceId: string): ToolSet {
|
||||
return {
|
||||
create_view_filter: {
|
||||
description:
|
||||
'Add a filter to a view. Use list_object_metadata_items to get fieldMetadataId values.',
|
||||
inputSchema: CreateViewFilterInputSchema,
|
||||
execute: async (parameters: {
|
||||
viewId: string;
|
||||
fieldMetadataId: string;
|
||||
operand: ViewFilterOperand;
|
||||
value: unknown;
|
||||
subFieldName?: string;
|
||||
}) => {
|
||||
try {
|
||||
const filter = await this.viewFilterService.createOne({
|
||||
createViewFilterInput: {
|
||||
viewId: parameters.viewId,
|
||||
fieldMetadataId: parameters.fieldMetadataId,
|
||||
operand: parameters.operand,
|
||||
value: parameters.value as ViewFilterValue,
|
||||
subFieldName: parameters.subFieldName,
|
||||
},
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: filter.id,
|
||||
viewId: filter.viewId,
|
||||
fieldMetadataId: filter.fieldMetadataId,
|
||||
operand: filter.operand,
|
||||
value: filter.value,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
create_many_view_filters: {
|
||||
description:
|
||||
'Add multiple filters to a view in one call. Use list_object_metadata_items to get fieldMetadataId values.',
|
||||
inputSchema: CreateManyViewFiltersInputSchema,
|
||||
execute: async (parameters: {
|
||||
filters: Array<{
|
||||
viewId: string;
|
||||
fieldMetadataId: string;
|
||||
operand: ViewFilterOperand;
|
||||
value: unknown;
|
||||
subFieldName?: string;
|
||||
}>;
|
||||
}) => {
|
||||
const results = [];
|
||||
|
||||
for (const filterInput of parameters.filters) {
|
||||
try {
|
||||
const filter = await this.viewFilterService.createOne({
|
||||
createViewFilterInput: {
|
||||
viewId: filterInput.viewId,
|
||||
fieldMetadataId: filterInput.fieldMetadataId,
|
||||
operand: filterInput.operand,
|
||||
value: filterInput.value as ViewFilterValue,
|
||||
subFieldName: filterInput.subFieldName,
|
||||
},
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
results.push({
|
||||
id: filter.id,
|
||||
viewId: filter.viewId,
|
||||
fieldMetadataId: filter.fieldMetadataId,
|
||||
operand: filter.operand,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { created: results };
|
||||
},
|
||||
},
|
||||
update_view_filter: {
|
||||
description:
|
||||
'Update a filter on a view. Use get_view_filters to find the filter ID.',
|
||||
inputSchema: UpdateViewFilterInputSchema,
|
||||
execute: async (parameters: {
|
||||
id: string;
|
||||
operand?: ViewFilterOperand;
|
||||
value?: unknown;
|
||||
subFieldName?: string;
|
||||
}) => {
|
||||
try {
|
||||
const filter = await this.viewFilterService.updateOne({
|
||||
updateViewFilterInput: {
|
||||
id: parameters.id,
|
||||
update: {
|
||||
operand: parameters.operand,
|
||||
value: parameters.value as ViewFilterValue,
|
||||
subFieldName: parameters.subFieldName,
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: filter.id,
|
||||
viewId: filter.viewId,
|
||||
fieldMetadataId: filter.fieldMetadataId,
|
||||
operand: filter.operand,
|
||||
value: filter.value,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
delete_view_filter: {
|
||||
description:
|
||||
'Remove a filter from a view. Use get_view_filters to find the filter ID.',
|
||||
inputSchema: DeleteViewFilterInputSchema,
|
||||
execute: async (parameters: { id: string }) => {
|
||||
try {
|
||||
const filter = await this.viewFilterService.deleteOne({
|
||||
deleteViewFilterInput: { id: parameters.id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: filter.id,
|
||||
deleted: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -8,6 +8,7 @@ import { ViewFilterController } from 'src/engine/metadata-modules/view-filter/co
|
||||
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
|
||||
import { ViewFilterResolver } from 'src/engine/metadata-modules/view-filter/resolvers/view-filter.resolver';
|
||||
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
|
||||
import { ViewFilterToolsFactory } from 'src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory';
|
||||
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
@@ -24,7 +25,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
ViewPermissionsModule,
|
||||
],
|
||||
controllers: [ViewFilterController],
|
||||
providers: [ViewFilterService, ViewFilterResolver],
|
||||
exports: [ViewFilterService],
|
||||
providers: [ViewFilterService, ViewFilterResolver, ViewFilterToolsFactory],
|
||||
exports: [ViewFilterService, ViewFilterToolsFactory],
|
||||
})
|
||||
export class ViewFilterModule {}
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
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 { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
|
||||
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
|
||||
const VIEW_SORT_DIRECTION_OPTIONS = Object.values(ViewSortDirection);
|
||||
|
||||
const GetViewSortsInputSchema = z.object({
|
||||
viewId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('ID of the view to list sorts for. Obtain this from get_views.'),
|
||||
});
|
||||
|
||||
const CreateViewSortInputSchema = z.object({
|
||||
viewId: z.string().uuid().describe('ID of the view to add the sort to'),
|
||||
fieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe(
|
||||
'ID of the field to sort by. Use list_object_metadata_items to find field IDs.',
|
||||
),
|
||||
direction: z
|
||||
.enum(VIEW_SORT_DIRECTION_OPTIONS)
|
||||
.default(ViewSortDirection.ASC)
|
||||
.describe('Sort direction: ASC (ascending) or DESC (descending)'),
|
||||
});
|
||||
|
||||
const CreateManyViewSortsInputSchema = z.object({
|
||||
sorts: z
|
||||
.array(CreateViewSortInputSchema)
|
||||
.min(1)
|
||||
.max(10)
|
||||
.describe('Array of sorts to create (1-10 items)'),
|
||||
});
|
||||
|
||||
const UpdateViewSortInputSchema = z.object({
|
||||
id: z.string().uuid().describe('ID of the view sort to update'),
|
||||
direction: z
|
||||
.enum(VIEW_SORT_DIRECTION_OPTIONS)
|
||||
.optional()
|
||||
.describe('New sort direction: ASC or DESC'),
|
||||
});
|
||||
|
||||
const DeleteViewSortInputSchema = z.object({
|
||||
id: z.string().uuid().describe('ID of the view sort to delete'),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class ViewSortToolsFactory {
|
||||
constructor(private readonly viewSortService: ViewSortService) {}
|
||||
|
||||
generateReadTools(workspaceId: string): ToolSet {
|
||||
return {
|
||||
get_view_sorts: {
|
||||
description:
|
||||
'List all sorts applied to a view. Each sort defines a field and direction that determines the order records appear in the view.',
|
||||
inputSchema: GetViewSortsInputSchema,
|
||||
execute: async (parameters: { viewId: string }) => {
|
||||
const sorts = await this.viewSortService.findByViewId(
|
||||
workspaceId,
|
||||
parameters.viewId,
|
||||
);
|
||||
|
||||
return sorts.map((sort) => ({
|
||||
id: sort.id,
|
||||
viewId: sort.viewId,
|
||||
fieldMetadataId: sort.fieldMetadataId,
|
||||
direction: sort.direction,
|
||||
}));
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
generateWriteTools(workspaceId: string): ToolSet {
|
||||
return {
|
||||
create_view_sort: {
|
||||
description:
|
||||
'Add a sort to a view. Use list_object_metadata_items to get fieldMetadataId values.',
|
||||
inputSchema: CreateViewSortInputSchema,
|
||||
execute: async (parameters: {
|
||||
viewId: string;
|
||||
fieldMetadataId: string;
|
||||
direction: ViewSortDirection;
|
||||
}) => {
|
||||
try {
|
||||
const sort = await this.viewSortService.createOne({
|
||||
createViewSortInput: {
|
||||
viewId: parameters.viewId,
|
||||
fieldMetadataId: parameters.fieldMetadataId,
|
||||
direction: parameters.direction,
|
||||
},
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: sort.id,
|
||||
viewId: sort.viewId,
|
||||
fieldMetadataId: sort.fieldMetadataId,
|
||||
direction: sort.direction,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
create_many_view_sorts: {
|
||||
description:
|
||||
'Add multiple sorts to a view in one call. Use list_object_metadata_items to get fieldMetadataId values.',
|
||||
inputSchema: CreateManyViewSortsInputSchema,
|
||||
execute: async (parameters: {
|
||||
sorts: Array<{
|
||||
viewId: string;
|
||||
fieldMetadataId: string;
|
||||
direction: ViewSortDirection;
|
||||
}>;
|
||||
}) => {
|
||||
const results = [];
|
||||
|
||||
for (const sortInput of parameters.sorts) {
|
||||
try {
|
||||
const sort = await this.viewSortService.createOne({
|
||||
createViewSortInput: {
|
||||
viewId: sortInput.viewId,
|
||||
fieldMetadataId: sortInput.fieldMetadataId,
|
||||
direction: sortInput.direction,
|
||||
},
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
results.push({
|
||||
id: sort.id,
|
||||
viewId: sort.viewId,
|
||||
fieldMetadataId: sort.fieldMetadataId,
|
||||
direction: sort.direction,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { created: results };
|
||||
},
|
||||
},
|
||||
update_view_sort: {
|
||||
description:
|
||||
'Update a sort on a view. Use get_view_sorts to find the sort ID.',
|
||||
inputSchema: UpdateViewSortInputSchema,
|
||||
execute: async (parameters: {
|
||||
id: string;
|
||||
direction?: ViewSortDirection;
|
||||
}) => {
|
||||
try {
|
||||
const sort = await this.viewSortService.updateOne({
|
||||
updateViewSortInput: {
|
||||
id: parameters.id,
|
||||
update: {
|
||||
direction: parameters.direction,
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: sort.id,
|
||||
viewId: sort.viewId,
|
||||
fieldMetadataId: sort.fieldMetadataId,
|
||||
direction: sort.direction,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
delete_view_sort: {
|
||||
description:
|
||||
'Remove a sort from a view. Use get_view_sorts to find the sort ID.',
|
||||
inputSchema: DeleteViewSortInputSchema,
|
||||
execute: async (parameters: { id: string }) => {
|
||||
try {
|
||||
const sort = await this.viewSortService.deleteOne({
|
||||
deleteViewSortInput: { id: parameters.id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: sort.id,
|
||||
deleted: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ViewSortController } from 'src/engine/metadata-modules/view-sort/contro
|
||||
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
|
||||
import { ViewSortResolver } from 'src/engine/metadata-modules/view-sort/resolvers/view-sort.resolver';
|
||||
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
|
||||
import { ViewSortToolsFactory } from 'src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
|
||||
@@ -24,7 +25,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
|
||||
ViewPermissionsModule,
|
||||
],
|
||||
controllers: [ViewSortController],
|
||||
providers: [ViewSortService, ViewSortResolver],
|
||||
exports: [ViewSortService],
|
||||
providers: [ViewSortService, ViewSortResolver, ViewSortToolsFactory],
|
||||
exports: [ViewSortService, ViewSortToolsFactory],
|
||||
})
|
||||
export class ViewSortModule {}
|
||||
|
||||
+212
@@ -1,12 +1,16 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import {
|
||||
FieldMetadataType,
|
||||
OrderByDirection,
|
||||
ViewType,
|
||||
ViewVisibility,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
|
||||
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
|
||||
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';
|
||||
@@ -14,6 +18,7 @@ import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-to
|
||||
describe('ViewToolsFactory', () => {
|
||||
let viewToolsFactory: ViewToolsFactory;
|
||||
let viewService: jest.Mocked<ViewService>;
|
||||
let viewFieldService: jest.Mocked<ViewFieldService>;
|
||||
let viewQueryParamsService: jest.Mocked<ViewQueryParamsService>;
|
||||
let _flatEntityMapsCacheService: jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>;
|
||||
|
||||
@@ -22,6 +27,36 @@ describe('ViewToolsFactory', () => {
|
||||
const mockViewId = 'view-id';
|
||||
const mockObjectMetadataId = 'object-metadata-id';
|
||||
const mockObjectNameSingular = 'company';
|
||||
const mockCalendarFieldMetadataId = 'calendar-field-metadata-id';
|
||||
|
||||
const mockNameFieldMetadataId = 'name-field-metadata-id';
|
||||
const mockStageFieldMetadataId = 'stage-field-metadata-id';
|
||||
|
||||
const mockFlatFieldMetadataMaps = {
|
||||
byUniversalIdentifier: {
|
||||
'field-universal-id': {
|
||||
id: mockCalendarFieldMetadataId,
|
||||
name: 'dueAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
universalIdentifier: 'field-universal-id',
|
||||
},
|
||||
'name-field-universal-id': {
|
||||
id: mockNameFieldMetadataId,
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
universalIdentifier: 'name-field-universal-id',
|
||||
},
|
||||
'stage-field-universal-id': {
|
||||
id: mockStageFieldMetadataId,
|
||||
name: 'stage',
|
||||
type: FieldMetadataType.SELECT,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
universalIdentifier: 'stage-field-universal-id',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockView = {
|
||||
id: mockViewId,
|
||||
@@ -70,6 +105,12 @@ describe('ViewToolsFactory', () => {
|
||||
deleteOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ViewFieldService,
|
||||
useValue: {
|
||||
createMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ViewQueryParamsService,
|
||||
useValue: {
|
||||
@@ -81,6 +122,7 @@ describe('ViewToolsFactory', () => {
|
||||
useValue: {
|
||||
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
|
||||
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps: mockFlatFieldMetadataMaps,
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -89,6 +131,7 @@ describe('ViewToolsFactory', () => {
|
||||
|
||||
viewToolsFactory = module.get<ViewToolsFactory>(ViewToolsFactory);
|
||||
viewService = module.get(ViewService);
|
||||
viewFieldService = module.get(ViewFieldService);
|
||||
viewQueryParamsService = module.get(ViewQueryParamsService);
|
||||
_flatEntityMapsCacheService = module.get(
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
@@ -275,6 +318,175 @@ describe('ViewToolsFactory', () => {
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create view fields when fieldNames is provided', async () => {
|
||||
const createdView = {
|
||||
id: 'new-view-id',
|
||||
name: 'Kanban View',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: ViewType.KANBAN,
|
||||
icon: 'IconLayoutKanban',
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
};
|
||||
|
||||
viewService.createOne.mockResolvedValue(createdView as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await callExecute(tools['create_view'], {
|
||||
name: 'Kanban View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
icon: 'IconLayoutKanban',
|
||||
type: ViewType.KANBAN,
|
||||
mainGroupByFieldName: 'stage',
|
||||
fieldNames: ['name', 'stage'],
|
||||
});
|
||||
|
||||
expect(viewFieldService.createMany).toHaveBeenCalledWith({
|
||||
createViewFieldInputs: [
|
||||
{
|
||||
viewId: 'new-view-id',
|
||||
fieldMetadataId: mockNameFieldMetadataId,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
viewId: 'new-view-id',
|
||||
fieldMetadataId: mockStageFieldMetadataId,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when KANBAN view missing mainGroupByFieldName', async () => {
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await expect(
|
||||
callExecute(tools['create_view'], {
|
||||
name: 'Kanban View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
type: ViewType.KANBAN,
|
||||
}),
|
||||
).rejects.toThrow('KANBAN views require mainGroupByFieldName');
|
||||
});
|
||||
|
||||
it('should throw when CALENDAR view missing calendarFieldName', async () => {
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await expect(
|
||||
callExecute(tools['create_view'], {
|
||||
name: 'Calendar View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
type: ViewType.CALENDAR,
|
||||
calendarLayout: ViewCalendarLayout.WEEK,
|
||||
}),
|
||||
).rejects.toThrow('CALENDAR views require calendarFieldName');
|
||||
});
|
||||
|
||||
it('should throw when CALENDAR view missing calendarLayout', async () => {
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await expect(
|
||||
callExecute(tools['create_view'], {
|
||||
name: 'Calendar View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
type: ViewType.CALENDAR,
|
||||
calendarFieldName: 'dueAt',
|
||||
}),
|
||||
).rejects.toThrow('CALENDAR views require calendarLayout');
|
||||
});
|
||||
|
||||
it('should not create view fields when fieldNames is not provided', 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,
|
||||
);
|
||||
|
||||
await callExecute(tools['create_view'], {
|
||||
name: 'New View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
icon: 'IconTable',
|
||||
});
|
||||
|
||||
expect(viewFieldService.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a calendar view with layout and field', async () => {
|
||||
const createdView = {
|
||||
id: 'new-calendar-view-id',
|
||||
name: 'Calendar View',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: ViewType.CALENDAR,
|
||||
icon: 'IconCalendar',
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
};
|
||||
|
||||
viewService.createOne.mockResolvedValue(createdView as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['create_view'], {
|
||||
name: 'Calendar View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
icon: 'IconCalendar',
|
||||
type: ViewType.CALENDAR,
|
||||
calendarLayout: ViewCalendarLayout.WEEK,
|
||||
calendarFieldName: 'dueAt',
|
||||
});
|
||||
|
||||
expect(viewService.createOne).toHaveBeenCalledWith({
|
||||
createViewInput: {
|
||||
name: 'Calendar View',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
icon: 'IconCalendar',
|
||||
type: ViewType.CALENDAR,
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
calendarLayout: ViewCalendarLayout.WEEK,
|
||||
calendarFieldMetadataId: mockCalendarFieldMetadataId,
|
||||
},
|
||||
workspaceId: mockWorkspaceId,
|
||||
createdByUserWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 'new-calendar-view-id',
|
||||
name: 'Calendar View',
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
type: ViewType.CALENDAR,
|
||||
icon: 'IconCalendar',
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update-view tool', () => {
|
||||
|
||||
+171
-7
@@ -1,19 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AggregateOperations,
|
||||
FieldMetadataType,
|
||||
ViewType,
|
||||
ViewVisibility,
|
||||
} from 'twenty-shared/types';
|
||||
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 { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
|
||||
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
|
||||
|
||||
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.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 { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
import { isFieldMetadataDateKind, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
const GetViewsInputSchema = z.object({
|
||||
objectNameSingular: z
|
||||
@@ -76,6 +81,28 @@ const CreateViewInputSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Field name for the kanban aggregate operation (e.g., "amount")'),
|
||||
calendarLayout: z
|
||||
.enum([
|
||||
ViewCalendarLayout.DAY,
|
||||
ViewCalendarLayout.WEEK,
|
||||
ViewCalendarLayout.MONTH,
|
||||
])
|
||||
.optional()
|
||||
.describe(
|
||||
'Calendar layout (required for CALENDAR views, e.g., "DAY", "WEEK", "MONTH")',
|
||||
),
|
||||
calendarFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Date field name to use for the calendar (required for CALENDAR views, must be a DATE or DATE_TIME field, e.g., "createdAt", "dueAt")',
|
||||
),
|
||||
fieldNames: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
'Field names to display in the view as columns (for TABLE) or cards (for KANBAN/CALENDAR). Fields are displayed in the order provided. Use get_field_metadata to find available field names.',
|
||||
),
|
||||
});
|
||||
|
||||
const UpdateViewInputSchema = z.object({
|
||||
@@ -92,6 +119,7 @@ const DeleteViewInputSchema = z.object({
|
||||
export class ViewToolsFactory {
|
||||
constructor(
|
||||
private readonly viewService: ViewService,
|
||||
private readonly viewFieldService: ViewFieldService,
|
||||
private readonly viewQueryParamsService: ViewQueryParamsService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
) {}
|
||||
@@ -153,6 +181,78 @@ export class ViewToolsFactory {
|
||||
return fieldMetadata.id;
|
||||
}
|
||||
|
||||
private async resolveGroupByFieldMetadataId(
|
||||
workspaceId: string,
|
||||
objectMetadataId: string,
|
||||
fieldName: string,
|
||||
): Promise<string> {
|
||||
const { flatFieldMetadataMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatFieldMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const fieldMetadata = Object.values(
|
||||
flatFieldMetadataMaps.byUniversalIdentifier,
|
||||
).find(
|
||||
(field) =>
|
||||
field?.name === fieldName &&
|
||||
field?.objectMetadataId === objectMetadataId,
|
||||
);
|
||||
|
||||
if (!fieldMetadata) {
|
||||
throw new Error(
|
||||
`Field "${fieldName}" not found on this object. Use get_field_metadata to list available fields.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldMetadata.type !== FieldMetadataType.SELECT) {
|
||||
throw new Error(
|
||||
`Field "${fieldName}" has type "${fieldMetadata.type}" and cannot be used as a group-by field. Only SELECT fields are supported for grouping (board columns and table groups).`,
|
||||
);
|
||||
}
|
||||
|
||||
return fieldMetadata.id;
|
||||
}
|
||||
|
||||
private async resolveCalendarFieldMetadataId(
|
||||
workspaceId: string,
|
||||
objectMetadataId: string,
|
||||
fieldName: string,
|
||||
): Promise<string> {
|
||||
const { flatFieldMetadataMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatFieldMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const fieldMetadata = Object.values(
|
||||
flatFieldMetadataMaps.byUniversalIdentifier,
|
||||
).find(
|
||||
(field) =>
|
||||
field?.name === fieldName &&
|
||||
field?.objectMetadataId === objectMetadataId,
|
||||
);
|
||||
|
||||
if (!fieldMetadata) {
|
||||
throw new Error(
|
||||
`Field "${fieldName}" not found on this object. Use get_field_metadata to list available fields.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFieldMetadataDateKind(fieldMetadata.type)) {
|
||||
throw new Error(
|
||||
`Field "${fieldName}" has type "${fieldMetadata.type}" and cannot be used as a calendar field. Only DATE or DATE_TIME fields are supported.`,
|
||||
);
|
||||
}
|
||||
|
||||
return fieldMetadata.id;
|
||||
}
|
||||
|
||||
generateReadTools(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
@@ -219,7 +319,7 @@ export class ViewToolsFactory {
|
||||
return {
|
||||
create_view: {
|
||||
description:
|
||||
'Create a new view for an object. Views define how records are displayed. For KANBAN views, mainGroupByFieldName is required and must be a SELECT field (e.g., "stage", "status").',
|
||||
'Create a new view for an object. Views define how records are displayed. For KANBAN views, mainGroupByFieldName is required and must be a SELECT field (e.g., "stage", "status"). For CALENDAR views, calendarFieldName and calendarLayout are required.',
|
||||
inputSchema: CreateViewInputSchema,
|
||||
execute: async (parameters: {
|
||||
name: string;
|
||||
@@ -230,6 +330,9 @@ export class ViewToolsFactory {
|
||||
mainGroupByFieldName?: string;
|
||||
kanbanAggregateOperation?: string;
|
||||
kanbanAggregateOperationFieldName?: string;
|
||||
calendarLayout?: ViewCalendarLayout;
|
||||
calendarFieldName?: string;
|
||||
fieldNames?: string[];
|
||||
}) => {
|
||||
try {
|
||||
const objectMetadataId = await this.resolveObjectMetadataId(
|
||||
@@ -237,15 +340,40 @@ export class ViewToolsFactory {
|
||||
parameters.objectNameSingular,
|
||||
);
|
||||
|
||||
if (
|
||||
parameters.type === ViewType.KANBAN &&
|
||||
!parameters.mainGroupByFieldName
|
||||
) {
|
||||
throw new Error(
|
||||
'KANBAN views require mainGroupByFieldName. Provide a SELECT field name (e.g., "stage", "status") to group records into columns.',
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters.type === ViewType.CALENDAR) {
|
||||
if (!parameters.calendarFieldName) {
|
||||
throw new Error(
|
||||
'CALENDAR views require calendarFieldName. Provide a DATE or DATE_TIME field name (e.g., "dueAt", "createdAt").',
|
||||
);
|
||||
}
|
||||
|
||||
if (!parameters.calendarLayout) {
|
||||
throw new Error(
|
||||
'CALENDAR views require calendarLayout. Provide one of: "DAY", "WEEK", "MONTH".',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mainGroupByFieldMetadataId: string | undefined;
|
||||
let kanbanAggregateOperationFieldMetadataId: string | undefined;
|
||||
let calendarFieldMetadataId: string | undefined;
|
||||
|
||||
if (parameters.mainGroupByFieldName) {
|
||||
mainGroupByFieldMetadataId = await this.resolveFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
parameters.mainGroupByFieldName,
|
||||
);
|
||||
mainGroupByFieldMetadataId =
|
||||
await this.resolveGroupByFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
parameters.mainGroupByFieldName,
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters.kanbanAggregateOperationFieldName) {
|
||||
@@ -257,6 +385,15 @@ export class ViewToolsFactory {
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters.calendarFieldName) {
|
||||
calendarFieldMetadataId =
|
||||
await this.resolveCalendarFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
parameters.calendarFieldName,
|
||||
);
|
||||
}
|
||||
|
||||
const view = await this.viewService.createOne({
|
||||
createViewInput: {
|
||||
name: parameters.name,
|
||||
@@ -268,11 +405,38 @@ export class ViewToolsFactory {
|
||||
kanbanAggregateOperation:
|
||||
parameters.kanbanAggregateOperation as AggregateOperations,
|
||||
kanbanAggregateOperationFieldMetadataId,
|
||||
calendarLayout: parameters.calendarLayout,
|
||||
calendarFieldMetadataId,
|
||||
},
|
||||
workspaceId,
|
||||
createdByUserWorkspaceId: userWorkspaceId,
|
||||
});
|
||||
|
||||
if (isNonEmptyArray(parameters.fieldNames)) {
|
||||
const resolvedFieldMetadataIds = await Promise.all(
|
||||
parameters.fieldNames.map((fieldName) =>
|
||||
this.resolveFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
fieldName,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await this.viewFieldService.createMany({
|
||||
createViewFieldInputs: resolvedFieldMetadataIds.map(
|
||||
(fieldMetadataId, index) => ({
|
||||
viewId: view.id,
|
||||
fieldMetadataId,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
position: index,
|
||||
}),
|
||||
),
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { FlatViewModule } from 'src/engine/metadata-modules/flat-view/flat-view.
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { ViewFieldGroupModule } from 'src/engine/metadata-modules/view-field-group/view-field-group.module';
|
||||
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
|
||||
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
|
||||
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
|
||||
import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module';
|
||||
import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller';
|
||||
@@ -24,6 +26,8 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
TypeOrmModule.forFeature([ViewEntity]),
|
||||
ViewPermissionsModule,
|
||||
ViewFieldGroupModule,
|
||||
ViewFieldModule,
|
||||
ViewFilterModule,
|
||||
ViewSortModule,
|
||||
I18nModule,
|
||||
ApplicationModule,
|
||||
|
||||
+9
@@ -32,6 +32,15 @@ export const STANDARD_SKILL = {
|
||||
'workspace-demo-seeding': {
|
||||
universalIdentifier: '20202020-c81b-4af8-9255-4c34bd0eac9c',
|
||||
},
|
||||
'view-building': {
|
||||
universalIdentifier: '20202020-e4a2-4b3f-9c71-d8f6a2b51e3a',
|
||||
},
|
||||
'view-filters-and-sorts': {
|
||||
universalIdentifier: '20202020-f5b3-4c4e-8d82-e9a7b3c62f4b',
|
||||
},
|
||||
'custom-objects-cleanup': {
|
||||
universalIdentifier: '20202020-a1d3-4e5f-b6c7-8d9e0f1a2b3c',
|
||||
},
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
{
|
||||
|
||||
+401
-25
@@ -149,22 +149,27 @@ Prioritize data integrity and provide clear feedback on operations performed.`,
|
||||
'Seeding demo metadata and data for workspace setup and testing purposes',
|
||||
icon: 'IconDatabase',
|
||||
content: `# Workspace Demo Seeding Skill
|
||||
You will create a demo workspace that fits a particular type of company given by the user.
|
||||
You will transform the existing standard workspace into a fully custom demo tailored to the user's business type.
|
||||
|
||||
Do not ask the user for more information, just be creative with the objects and fields, but stay professional and coherent.
|
||||
The goal is to tell a coherent and realistic story with the data: custom fields added to standard objects, new custom objects for domain-specific entities, rich relations, seeded and updated records, views, and enrichment data (emails, calendar events, tasks, notes, files) that make the workspace feel like a real company in operation.
|
||||
|
||||
The goal is to be able to tell a coherent and realistic story with the data, so for example if the user says it is a car repair shop,
|
||||
we can create objects for cars, employees, repairs, customers, and the relevant relations between them,
|
||||
and then we can seed data that tells the story of the car repair shop, for example we can create a customer, then create a car for that customer, then create a repair for that car, and so on.
|
||||
We should end up with a dashboard that shows the relevant metrics for the car repair shop, for example the number of repairs, the revenue, the most common car brands, and so on.
|
||||
## Object strategy
|
||||
|
||||
Create relations fields between objects, for example a car repair shop workspace would have objects for cars, employees, repairs, customers, and the relevant relations between them.
|
||||
**Keep the standard objects — People, Companies, and Opportunities — and reuse their existing seed data.** They already have emails and calendar events linked to them as participants. The demo story is built on top of them, not instead of them.
|
||||
|
||||
Create 5 to 7 objects, with 5 to 8 fields each, and the relevant relations between them.
|
||||
- **People** → map to the domain's "contact" role (e.g. clients, candidates, customers, agents)
|
||||
- **Companies** → map to the domain's "organisation" role (e.g. suppliers, agencies, employers)
|
||||
- **Opportunities** → map to the domain's "deal/pipeline" role (e.g. job applications, deals, repair estimates)
|
||||
|
||||
**Add 2 to 3 additional custom objects** for domain-specific entities that don't map to People/Companies/Opportunities (e.g. Properties, Cars, Products, Projects). Keep the custom object count low — the standard objects carry most of the story.
|
||||
|
||||
**Add custom fields** to People, Companies, and Opportunities to enrich them with domain-specific data (e.g. add "specialisation", "licenseNumber" to People; add "industry vertical", "tier" to Companies; add "stage", "closeDate" to Opportunities).
|
||||
|
||||
Create rich relation fields between standard and custom objects to show off the relational capabilities of the platform.
|
||||
|
||||
If you have to create multiple things you *MUST* use the relevant create many tool if it exists:
|
||||
- Use *create_many_object_metadata* to create all objects at once
|
||||
- Use *create_many_field_metadata* to create all non-relation fields at once
|
||||
- Use *create_many_object_metadata* to create all custom objects at once
|
||||
- Use *create_many_field_metadata* to create all non-relation fields at once (including new fields on standard objects)
|
||||
- Use *create_many_relation_fields* to create all relation fields between objects at once (do this AFTER creating the objects and non-relation fields)
|
||||
|
||||
If you have to wait use the navigate tool.
|
||||
@@ -174,14 +179,30 @@ For the fields you will create, make sure to create a good variety of field type
|
||||
- Create DATE_TIME fields to be able to create calendar views
|
||||
- Create CURRENCY and NUMERIC fields for graphs
|
||||
|
||||
*Here are the steps to follow closely :*
|
||||
*Here are the steps to follow closely:*
|
||||
|
||||
STEP 1: Create all the objects at once with create_many_object_metadata, DO THIS FIRST
|
||||
STEP 0: Present a plan to the user and wait for approval.
|
||||
- Use list_object_metadata_items to see all available objects in the workspace
|
||||
- Use find_people (limit: 5) and find_companies (limit: 5) and find_opportunities (limit: 5) to understand the existing seed data shape
|
||||
- Based on the user's business type, propose a plan that lists:
|
||||
- How People, Companies, and Opportunities map to the domain story (e.g. "People = Candidates", "Companies = Employers")
|
||||
- The 2–3 custom objects you will create, each with a one-line description of their role
|
||||
- The custom fields you will add to People, Companies, and Opportunities
|
||||
- A brief description of the key relations between objects
|
||||
- Present this plan to the user and *wait for their confirmation or adjustments* before proceeding
|
||||
- Once approved, call the custom-objects-cleanup skill to archive all existing custom objects without asking for user confirmation
|
||||
- Wait 3 seconds after deletions for the backend side effects to be completed
|
||||
|
||||
STEP 1: Create all the custom objects at once with create_many_object_metadata
|
||||
name must start with lowercase letter and contain only alphanumeric letters
|
||||
|
||||
STEP 2: Wait 3 seconds, for the backend side effects to be completed
|
||||
|
||||
STEP 3: Create all NON-RELATION fields for ALL objects by batch with create_many_field_metadata, do a batch for each object.
|
||||
STEP 3: Create all NON-RELATION fields for ALL objects by batch with create_many_field_metadata.
|
||||
Do a separate batch call for each object.
|
||||
This includes:
|
||||
- New custom fields for the standard objects (Person, Company, Opportunity) — use their objectMetadataId from list_object_metadata_items
|
||||
- All non-relation fields for the new custom objects
|
||||
DO NOT include relation fields in this step. Only create TEXT, NUMBER, BOOLEAN, DATE_TIME, SELECT, MULTI_SELECT, CURRENCY, etc.
|
||||
SELECT option values must be UPPER_SNAKE_CASE
|
||||
|
||||
@@ -193,25 +214,100 @@ targetFieldIcon is like IconSomething, it's ok if it doesn't exist in the icon l
|
||||
|
||||
STEP 6: Wait 3 seconds, for the backend side effects to be completed
|
||||
|
||||
STEP 7: For each object:
|
||||
- Navigate the object's default view USE THE NAVIGATE TOOL
|
||||
STEP 7: Rename and enrich the first N records of People, Companies, and Opportunities.
|
||||
- Use find_people (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_companies (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_opportunities (limit: 50, orderBy: [{ position: "AscNullsFirst" }]) to get the IDs of the first records in each table
|
||||
- Ordering by position ascending gives the earliest-inserted records, which are contiguous in the table — this keeps the demo data tightly grouped and makes the workspace feel coherent
|
||||
- For each standard object, call update_people / update_companies / update_opportunities **individually per record** (one call per record) to set domain-relevant names and field values:
|
||||
- **People**: replace nameFirstName + nameLastName with realistic names that fit the domain role (e.g. for a law firm: "Sophie Martin", "James O'Brien"; for a clinic: "Dr. Clara Reyes", "Marco Bianchi"). Also set jobTitle to a domain-appropriate title.
|
||||
- **Companies**: replace name with realistic company names that fit the domain (e.g. for a law firm: "Ashford & Partners", "Nexus Legal Group"; for a clinic: "Meridian Health Clinic", "CarePoint Medical").
|
||||
- **Opportunities**: replace name with a domain-relevant deal name (e.g. "Q2 retainer — Ashford & Partners", "New patient intake — Meridian Health").
|
||||
- Also set the new custom fields on each record: spread realistic values across SELECT fields, set plausible CURRENCY/NUMERIC amounts, set DATE_TIME fields around TODAY.
|
||||
- Do this one record at a time — the API does not support bulk individual updates with different values per record
|
||||
- Wait 3 seconds after finishing all updates for one object type before moving to the next
|
||||
|
||||
STEP 7.5: Add view fields to the default views of standard objects to expose the new custom fields.
|
||||
For each of People, Companies, and Opportunities:
|
||||
- Navigate to the object's default view using the navigate tool
|
||||
- Wait 3 seconds
|
||||
- Use create_many_view_fields to add all the new custom fields to the default view so they are visible
|
||||
- Use decimal positions between 0 and 1 to insert them right after the label identifier field
|
||||
- Navigate to the object's default view again using the navigate tool so the user can see the enriched records
|
||||
- Wait 3 seconds
|
||||
|
||||
STEP 8: For each new custom object, repeat ALL of the following sub-steps before moving to the next object:
|
||||
- Navigate the object's default view using the navigate tool
|
||||
- Wait 3 seconds, so the user has time to see the object default view
|
||||
- Create the view fields for the default view, use the create_many_view_fields tool, and make sure to include all created fields, including the relation fields, so that we have a complete view of the object with all its fields.
|
||||
BE CAREFUL to use a position that will put those view fields right after the first label identifier field
|
||||
which has a position of 0 and the next system created fields which begin at 1, *so use decimal positions between 0 and 1*
|
||||
*YOU MUST CREATE ALL VIEW FIELDS FOR ALL FIELDS, INCLUDING RELATION FIELDS, IN THIS STEP, DO NOT LEAVE ANY FIELD WITHOUT A VIEW FIELD, OTHERWISE IT WILL NOT BE VISIBLE IN THE DEFAULT VIEW AND THE USER WON'T KNOW IT EXISTS*
|
||||
BE CAREFUL to use a position that will put those view fields right after the first label identifier field
|
||||
which has a position of 0 and the next system created fields which begin at 1, *so use decimal positions between 0 and 1*
|
||||
*YOU MUST CREATE ALL VIEW FIELDS FOR ALL FIELDS, INCLUDING RELATION FIELDS, IN THIS STEP, DO NOT LEAVE ANY FIELD WITHOUT A VIEW FIELD, OTHERWISE IT WILL NOT BE VISIBLE IN THE DEFAULT VIEW AND THE USER WON'T KNOW IT EXISTS*
|
||||
|
||||
- Then seed relevant and realistic mock data as we said earlier :
|
||||
- **MANDATORY**: Navigate to the object's default view again using the navigate tool — YOU MUST DO THIS BEFORE EACH OBJECT'S DATA SEEDING, every single time, without exception
|
||||
- Wait 3 seconds
|
||||
- Seed relevant and realistic mock data for this object:
|
||||
- use the relevant tool to create many records for this object
|
||||
- between 20 and 50
|
||||
- with a coherent combination of values
|
||||
- link records to existing People and Companies using the relation fields you created
|
||||
- use dates that are around TODAY so it's relevant for seeing past / future and present records
|
||||
|
||||
Loop STEP 7 for all the objects
|
||||
- **MANDATORY**: Navigate to the object's default view again using the navigate tool so the user can see the populated data — DO NOT SKIP THIS, even if you already navigated earlier in this loop iteration
|
||||
- Wait 3 seconds so the user has time to see the seeded records
|
||||
|
||||
STEP 8 : After you've finished with this part, let's proceed to the dashboard creation. We will create a dashboard with 4 graphs.
|
||||
- Create a new dashboard with a relevant story to showcase the data you've just created, for example if it's a car repair shop, you can create a dashboard that shows the number of repairs per month, the revenue per month, the most common car brands, and the distribution of repairs by employee.
|
||||
- Navigate to the dashboard page
|
||||
- Then create 2 to 3 additional views for this object, one at a time. For each view, complete ALL of the following sub-steps before creating the next view:
|
||||
- Create the view using the create_view tool:
|
||||
- If the object has a SELECT field (e.g. status, stage, priority, type), create a **KANBAN** view grouped by that SELECT field with a relevant name like "By Status", "Pipeline", "By Priority".
|
||||
- Set kanbanAggregateOperation to COUNT so each column shows the number of records.
|
||||
- If there is a CURRENCY or NUMERIC field, also set kanbanAggregateOperationFieldName to that field for a SUM aggregate view.
|
||||
- If the object has a DATE_TIME field (e.g. dueDate, closedAt, scheduledAt), create a **CALENDAR** view using that field with a relevant name like "Calendar", "Schedule", "Timeline".
|
||||
- Create a **TABLE** view with a meaningful group (mainGroupByFieldName set to a SELECT field) with a name like "By Type", "By Stage", "Grouped", or similar.
|
||||
- Use create_many_view_fields to add all relevant field columns to this view (using decimal positions between 0 and 1)
|
||||
- Add filters and sorts to this view:
|
||||
- **KANBAN views**: Sort by a CURRENCY or NUMERIC field DESC (biggest value first) if one exists, or by createdAt DESC. Add a filter to exclude archived/cancelled records if such a SELECT option exists.
|
||||
- **CALENDAR views**: Sort by the date field ASC (earliest events first). Add a filter using IS_IN_FUTURE or IS_RELATIVE to show only upcoming records by default.
|
||||
- **TABLE with groups**: Sort by createdAt DESC (most recent first) and add a filter on a meaningful field (e.g. status IS_NOT "CANCELLED", or amount GREATER_THAN_OR_EQUAL to some threshold that keeps ~80% of the records visible).
|
||||
- **MANDATORY**: Navigate to this view immediately using the navigate tool — YOU MUST DO THIS FOR EVERY SINGLE VIEW, right after its fields/filters/sorts are set up, without exception
|
||||
- Wait 3 seconds so the user can see the view and course-correct if needed
|
||||
|
||||
Also create additional views for the standard objects (People, Companies, Opportunities) that showcase the new custom fields:
|
||||
- For People: a KANBAN view grouped by the new SELECT field you added (e.g. "By Specialisation", "By Status")
|
||||
- For Opportunities: a KANBAN view grouped by the new stage/status field (pipeline view)
|
||||
- For Companies: a TABLE view grouped by the new SELECT field
|
||||
Navigate to each view after creating it. Wait 3 seconds.
|
||||
|
||||
Loop STEP 8 for all the custom objects
|
||||
|
||||
STEP 9: Create a multi-tab dashboard that tells the full story of the business.
|
||||
|
||||
Use create_complete_dashboard to create the first tab, then add_dashboard_tab + add_dashboard_widget for subsequent tabs.
|
||||
|
||||
**Structure: 3 tabs**
|
||||
|
||||
Tab 1 — "Overview": high-level KPIs and charts across the whole workspace
|
||||
- Row 0: 3–4 AGGREGATE_CHART widgets (KPIs) — one per key metric (e.g. total revenue from Opportunities, count of active People, count of open deals). columnSpan 3–4, rowSpan 3.
|
||||
- Row 3: 1–2 BAR_CHART or LINE_CHART widgets showing trends over time (group by a DATE_TIME field with MONTH granularity). columnSpan 6, rowSpan 7.
|
||||
- Row 3: 1 PIE_CHART showing distribution by a SELECT field (e.g. status, type). columnSpan 6, rowSpan 7.
|
||||
- Row 10: 1 STANDALONE_RICH_TEXT widget summarising the dashboard story. columnSpan 12, rowSpan 3.
|
||||
|
||||
Tab 2 — "[Domain object] pipeline" (e.g. "Deals", "Applications", "Repairs"): focus on Opportunities enriched with domain data
|
||||
- Before adding the RECORD_TABLE widget, run this 3-step sequence:
|
||||
1. create_view (type TABLE, name e.g. "Active Deals") → get the new viewId
|
||||
2. create_many_view_fields on the new viewId — add 4–6 key fields (name, the new stage/status SELECT, a CURRENCY/NUMERIC field, a DATE field, linked Person or Company). Use positions 0, 1, 2… and isVisible: true.
|
||||
3. create_many_view_filters + create_view_sort — e.g. filter out CLOSED/LOST records (SELECT IS_NOT "CLOSED"), sort by value DESC
|
||||
- Row 0: 1 RECORD_TABLE widget. Set objectMetadataId to Opportunity, configuration.viewId to the dedicated view. columnSpan 12, rowSpan 8.
|
||||
- Row 8: 1 BAR_CHART grouped by the stage SELECT field. columnSpan 6, rowSpan 7.
|
||||
- Row 8: 1 PIE_CHART or AGGREGATE_CHART on the CURRENCY field. columnSpan 6, rowSpan 7.
|
||||
|
||||
Tab 3 — "[Domain people role] list" (e.g. "Clients", "Candidates", "Contacts"): focus on People enriched with domain data
|
||||
- Before adding the RECORD_TABLE widget, run this 3-step sequence:
|
||||
1. create_view (type TABLE, name e.g. "All Clients") → get the new viewId
|
||||
2. create_many_view_fields — add 4–5 key fields (name, email, the new SELECT/status field, a DATE field, linked Company)
|
||||
3. create_view_sort — sort by createdAt DESC or by name ASC
|
||||
- Row 0: 1 RECORD_TABLE widget with the dedicated view. columnSpan 12, rowSpan 8.
|
||||
- Row 8: 2–3 AGGREGATE_CHART KPIs (count, totals). columnSpan 4, rowSpan 3.
|
||||
- Row 11: 1 BAR_CHART or LINE_CHART. columnSpan 12, rowSpan 7.
|
||||
|
||||
After creating the dashboard, navigate to the dashboard page.
|
||||
`,
|
||||
isCustom: false,
|
||||
},
|
||||
@@ -295,6 +391,15 @@ You help users create and manage dashboards with widgets.
|
||||
- STANDALONE_RICH_TEXT: configurationType "STANDALONE_RICH_TEXT" + body with markdown content
|
||||
- IMPORTANT: Put the actual text content in configuration.body.markdown, NOT in the widget title
|
||||
- Widget title should be a short label (e.g. "Notes", "Summary"), body.markdown holds the real content
|
||||
- RECORD_TABLE: configurationType "RECORD_TABLE" — displays a filterable, sortable record list
|
||||
- **MANDATORY 3-step pre-sequence before creating the widget**:
|
||||
1. call create_view (type TABLE, name e.g. "Repairs Dashboard Table") → get the new viewId
|
||||
2. call create_many_view_fields on the new viewId — add 4–6 of the most relevant fields (label identifier + key SELECT/DATE/CURRENCY fields). Use positions 0, 1, 2… and isVisible: true.
|
||||
3. call create_many_view_filters and/or create_view_sort on the new viewId to focus the table (e.g. filter out DONE/CANCELLED records, sort by createdAt DESC or a date field ASC)
|
||||
- Never reuse a record index view — widget views and record index views must be separate
|
||||
- Set objectMetadataId on the widget (top-level, required)
|
||||
- Set configuration.viewId to the UUID of the dedicated view (required)
|
||||
- columnSpan 12 (full width) or 6 (half width), rowSpan 6–10
|
||||
|
||||
Example (STANDALONE_RICH_TEXT):
|
||||
{
|
||||
@@ -302,12 +407,26 @@ Example (STANDALONE_RICH_TEXT):
|
||||
"body": { "markdown": "## Quarterly Summary\\n\\nKey metrics:\\n- Revenue up 15%\\n- 42 new deals closed\\n\\n**Next steps**: Focus on enterprise pipeline." }
|
||||
}
|
||||
|
||||
Example (RECORD_TABLE — always run the 3-step pre-sequence first):
|
||||
Step 1 — create_view: { "name": "Active Repairs", "objectNameSingular": "repair", "type": "TABLE" } → { "id": "<view-uuid>" }
|
||||
Step 2 — create_many_view_fields: { "viewFields": [{ "viewId": "<view-uuid>", "fieldMetadataId": "<status-field-uuid>", "position": 1, "isVisible": true }, { "viewId": "<view-uuid>", "fieldMetadataId": "<amount-field-uuid>", "position": 2, "isVisible": true }] }
|
||||
Step 3 — create_many_view_filters: { "filters": [{ "viewId": "<view-uuid>", "fieldMetadataId": "<status-field-uuid>", "operand": "IS_NOT", "value": "DONE" }] }
|
||||
Step 3b — create_view_sort: { "viewId": "<view-uuid>", "fieldMetadataId": "<createdAt-field-uuid>", "direction": "DESC" }
|
||||
Step 4 — add_dashboard_widget: { "type": "RECORD_TABLE", "objectMetadataId": "<repair-object-uuid>", "configuration": { "configurationType": "RECORD_TABLE", "viewId": "<view-uuid>" }, "gridPosition": { "row": 0, "column": 0, "rowSpan": 8, "columnSpan": 12 } }
|
||||
|
||||
## Tabs
|
||||
|
||||
Use add_dashboard_tab to create multiple tabs in a dashboard. Each tab has its own set of widgets.
|
||||
Good tab structure: one overview tab (KPIs + charts) + one or more detail tabs (RECORD_TABLE + focused charts).
|
||||
After creating a tab, use its returned tabId as pageLayoutTabId when calling add_dashboard_widget.
|
||||
|
||||
## Grid System
|
||||
|
||||
- 12 columns (0-11)
|
||||
- KPI widgets: rowSpan 2-4, columnSpan 3-4
|
||||
- Charts: rowSpan 6-8, columnSpan 6-12
|
||||
- Common layouts: 4 KPIs in a row (columnSpan 3), 2 charts side by side (columnSpan 6), full width chart (columnSpan 12)
|
||||
- Record tables: rowSpan 6-10, columnSpan 6-12 (full-width preferred)
|
||||
- Common layouts: 4 KPIs in a row (columnSpan 3), 2 charts side by side (columnSpan 6), full width chart or table (columnSpan 12)
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -315,7 +434,8 @@ Example (STANDALONE_RICH_TEXT):
|
||||
- Group related charts together
|
||||
- Use consistent heights within rows
|
||||
- Start simple, add complexity as needed
|
||||
- When modifying a chart, confirm whether the user wants to change settings or change chart type`,
|
||||
- When modifying a chart, confirm whether the user wants to change settings or change chart type
|
||||
- Use RECORD_TABLE widgets to give users direct access to filtered record lists without leaving the dashboard`,
|
||||
isCustom: false,
|
||||
},
|
||||
}),
|
||||
@@ -973,6 +1093,262 @@ para.paragraph_format.space_after = Pt(12)
|
||||
},
|
||||
}),
|
||||
|
||||
'view-building': (args: Omit<CreateStandardSkillArgs, 'context'>) =>
|
||||
createStandardSkillFlatMetadata({
|
||||
...args,
|
||||
context: {
|
||||
skillName: 'view-building',
|
||||
name: 'view-building',
|
||||
label: 'View Building',
|
||||
description:
|
||||
'Creating and configuring views (table, board/kanban, calendar) for objects to organize and visualize records',
|
||||
icon: 'IconLayoutBoard',
|
||||
content: `# View Building Skill
|
||||
|
||||
You help users create and configure views to organize how they see their records.
|
||||
|
||||
## View Types
|
||||
|
||||
- **TABLE**: Standard table/grid view. Works for any object. Default view type.
|
||||
- **KANBAN**: Board view grouped by a SELECT field. Best for pipeline/status-based workflows.
|
||||
- **CALENDAR**: Calendar view using a DATE or DATE_TIME field. Best for time-based records.
|
||||
|
||||
## Tools
|
||||
|
||||
- get_views - List existing views (filter by object name)
|
||||
- create_view - Create a new view
|
||||
- update_view - Update view name/icon
|
||||
- delete_view - Delete a view
|
||||
- create_many_view_fields - Add visible columns to a view
|
||||
- update_many_view_fields - Update column configuration
|
||||
- get_view_fields - List columns in a view
|
||||
- list_object_metadata_items - Discover objects and their fields
|
||||
- navigate_app - Navigate to a view after creation
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Identify the target object**: If the user didn't specify which object, ask them. Present available objects and explain what each holds:
|
||||
- **Company**: Business accounts (name, domain, employees, revenue, address)
|
||||
- **Person**: Contacts (name, email, phone, job title, company)
|
||||
- **Opportunity**: Pipeline deals (name, stage, amount, close date, company, contact)
|
||||
- **Task**: Action items (title, status, due date, assignee)
|
||||
- **Note**: Free-form notes (title, body)
|
||||
- Plus any custom objects in the workspace
|
||||
|
||||
2. **Choose the view type**: Suggest the best type based on the object's data:
|
||||
- TABLE: Good default for any object, great for browsing large datasets
|
||||
- KANBAN: Ideal when objects have a SELECT field representing stages/statuses (e.g., Opportunity → stage, Task → status)
|
||||
- CALENDAR: Ideal when objects have DATE/DATE_TIME fields (e.g., Opportunity → closeDate, Task → dueAt)
|
||||
|
||||
3. **Create the view**: Use create_view with the right parameters.
|
||||
- For KANBAN: The mainGroupByFieldName is required — ask user which SELECT field to group by, or suggest the most natural one.
|
||||
- For CALENDAR: The system will use the appropriate date field.
|
||||
- For TABLE: No special configuration needed.
|
||||
|
||||
4. **Configure view fields**: Use create_many_view_fields to add relevant columns. Choose fields that make sense for the view's purpose. Use decimal positions between 0 and 1 to place them after the label identifier field.
|
||||
|
||||
5. **Navigate**: Use navigate_app to show the user their new view.
|
||||
|
||||
## KANBAN Best Practices
|
||||
|
||||
- The grouping field must be a SELECT type
|
||||
- Common groupings: Opportunity by stage, Task by status
|
||||
- Optionally set kanbanAggregateOperation (COUNT, SUM, AVG, MIN, MAX) and kanbanAggregateOperationFieldName for column summaries
|
||||
- Example: Sum of amount per stage for Opportunity board
|
||||
|
||||
## CALENDAR Best Practices
|
||||
|
||||
- Requires a DATE or DATE_TIME field on the object
|
||||
- Best for: Opportunity close dates, Task due dates, any event-based data
|
||||
|
||||
## TABLE with Groups
|
||||
|
||||
- TABLE views can also be grouped by a field using mainGroupByFieldName
|
||||
- This creates collapsible sections in the table, organized by the grouping field values
|
||||
- Works with SELECT fields for categorical grouping
|
||||
|
||||
## Approach
|
||||
|
||||
- If the user is vague (e.g., "create a board"), ask which object they want to see
|
||||
- Suggest the most relevant view type based on the object's fields
|
||||
- After creating a view, always configure useful view fields and navigate to it
|
||||
- Explain what each view type does so users can make informed choices`,
|
||||
isCustom: false,
|
||||
},
|
||||
}),
|
||||
|
||||
'view-filters-and-sorts': (args: Omit<CreateStandardSkillArgs, 'context'>) =>
|
||||
createStandardSkillFlatMetadata({
|
||||
...args,
|
||||
context: {
|
||||
skillName: 'view-filters-and-sorts',
|
||||
name: 'view-filters-and-sorts',
|
||||
label: 'View Filters & Sorts',
|
||||
description:
|
||||
'Adding filters and sorts to views to focus on relevant records based on user needs',
|
||||
icon: 'IconFilter',
|
||||
content: `# View Filters & Sorts Skill
|
||||
|
||||
You help users add filters and sorts to their views so they see the most relevant records.
|
||||
|
||||
## Tools
|
||||
|
||||
- get_views - List existing views to find the one to modify
|
||||
- get_view_query_parameters - Check existing filters and sorts on a view
|
||||
- list_object_metadata_items - Discover fields and their types to build valid filters
|
||||
- create_view_filter / create_many_view_filters - Add filters to a view
|
||||
- create_view_sort / create_many_view_sorts - Add sorts to a view
|
||||
- navigate_app - Navigate to the view to show results
|
||||
|
||||
## Filter Operators by Field Type
|
||||
|
||||
| Field Type | Available Operators |
|
||||
|---|---|
|
||||
| TEXT, EMAILS, FULL_NAME, ADDRESS, LINKS, PHONES | CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY |
|
||||
| NUMBER, NUMERIC | IS, IS_NOT, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS_EMPTY, IS_NOT_EMPTY |
|
||||
| CURRENCY | GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS_EMPTY, IS_NOT_EMPTY |
|
||||
| DATE, DATE_TIME | IS, IS_RELATIVE, IS_IN_PAST, IS_IN_FUTURE, IS_TODAY, IS_BEFORE, IS_AFTER, IS_EMPTY, IS_NOT_EMPTY |
|
||||
| SELECT | IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY |
|
||||
| MULTI_SELECT, ARRAY | CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY |
|
||||
| RELATION | IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY |
|
||||
| BOOLEAN | IS |
|
||||
|
||||
## Sort Directions
|
||||
|
||||
- ASC: Ascending (A→Z, 0→9, oldest→newest)
|
||||
- DESC: Descending (Z→A, 9→0, newest→oldest)
|
||||
|
||||
## Filter Groups (AND/OR/NOT)
|
||||
|
||||
Filters can be grouped with logical operators:
|
||||
- **AND**: All filters must match (default)
|
||||
- **OR**: At least one filter must match
|
||||
- **NOT**: Negate the group
|
||||
- Groups can be nested for complex conditions like: name CONTAINS "tech" AND (revenue > 1M OR employees > 100)
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Identify the view**: If the user didn't specify a view, ask which view they want to filter/sort. Use get_views to list available views and present them.
|
||||
|
||||
2. **Understand the need**: If the user hasn't described what they want to see, ask them. Give guidance with examples:
|
||||
- "What records do you want to focus on? For example:"
|
||||
- "Show only high-value opportunities (amount > $50K)"
|
||||
- "Show companies in a specific city or industry"
|
||||
- "Show tasks due this week, sorted by priority"
|
||||
- "Show people from a specific company"
|
||||
- "Show recent records created in the last 30 days"
|
||||
|
||||
3. **Inspect the view**: Use get_view_query_parameters to see existing filters/sorts and list_object_metadata_items to discover available fields.
|
||||
|
||||
4. **Build filters**: Based on the user's need, determine:
|
||||
- Which field(s) to filter on
|
||||
- Which operator is valid for that field type (see table above)
|
||||
- What value to filter by
|
||||
- Whether to use AND or OR grouping for multiple filters
|
||||
|
||||
5. **Build sorts**: Determine:
|
||||
- Which field to sort by (most relevant to the user's goal)
|
||||
- Direction: ASC or DESC
|
||||
- Multiple sorts can be added (primary, secondary, etc.)
|
||||
|
||||
6. **Apply and navigate**: Create the filters/sorts on the view and navigate to it.
|
||||
|
||||
## Common Filter Patterns
|
||||
|
||||
### By Time
|
||||
- Recent records: DATE_TIME field + IS_AFTER + a date value
|
||||
- Upcoming deadlines: DATE field + IS_IN_FUTURE
|
||||
- Overdue tasks: DATE field + IS_IN_PAST + status IS_NOT "DONE"
|
||||
- This week/month: DATE field + IS_RELATIVE
|
||||
|
||||
### By Status/Stage
|
||||
- Open opportunities: stage IS "IN_PROGRESS" or IS_NOT "WON"/"LOST"
|
||||
- Active tasks: status IS_NOT "DONE"
|
||||
|
||||
### By Relationship
|
||||
- Records linked to a company: company relation IS [specific company]
|
||||
- Unassigned tasks: assignee IS_EMPTY
|
||||
- Orphaned records: relation field IS_EMPTY
|
||||
|
||||
### By Value
|
||||
- High-value deals: amount GREATER_THAN_OR_EQUAL threshold
|
||||
- Large companies: employees GREATER_THAN_OR_EQUAL threshold
|
||||
|
||||
## Common Sort Patterns
|
||||
|
||||
- Pipeline view: Sort by amount DESC (biggest deals first)
|
||||
- Task management: Sort by dueAt ASC (earliest due first)
|
||||
- Recent activity: Sort by updatedAt DESC or createdAt DESC
|
||||
- Alphabetical: Sort by name ASC
|
||||
|
||||
## Composite Fields
|
||||
|
||||
Some fields have sub-fields that can be filtered:
|
||||
- CURRENCY: Use subFieldName "amountMicros" for the numeric value
|
||||
- ADDRESS: Use subFieldName like "addressCity", "addressCountry"
|
||||
- FULL_NAME: Use subFieldName like "firstName", "lastName"
|
||||
- EMAILS: Use the primary email
|
||||
- LINKS: Use the primary link URL
|
||||
|
||||
## Approach
|
||||
|
||||
- Always check field types before suggesting operators — using an invalid operator for a field type will fail
|
||||
- When the user says "show me X", translate that into the appropriate filter logic
|
||||
- Suggest sorts that complement the filters (e.g., if filtering overdue tasks, sort by dueAt ASC)
|
||||
- Explain what the filters do so users understand the results
|
||||
- If complex filtering is needed (AND + OR), explain the logic clearly`,
|
||||
isCustom: false,
|
||||
},
|
||||
}),
|
||||
|
||||
'custom-objects-cleanup': (args: Omit<CreateStandardSkillArgs, 'context'>) =>
|
||||
createStandardSkillFlatMetadata({
|
||||
...args,
|
||||
context: {
|
||||
skillName: 'custom-objects-cleanup',
|
||||
name: 'custom-objects-cleanup',
|
||||
label: 'Custom Objects Cleanup',
|
||||
description:
|
||||
'Archiving custom objects from a workspace (e.g. dev seed objects like pets, rockets)',
|
||||
icon: 'IconArchive',
|
||||
content: `# Custom Objects Cleanup Skill
|
||||
|
||||
You help users archive custom objects from their workspace, such as objects created by the dev seed (pets, rockets, survey results, etc.) or any other custom objects.
|
||||
|
||||
## Tools
|
||||
|
||||
- list_object_metadata_items - List all objects in the workspace to identify custom ones
|
||||
- update_many_object_metadata - Archive custom objects by setting isActive to false
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **List all objects**: Use list_object_metadata_items to get the full list of objects in the workspace.
|
||||
|
||||
2. **Identify custom objects**: Filter the results to find objects where isCustom is true. These are the objects that were created by users or by the dev seed, as opposed to standard built-in objects (Company, Person, Opportunity, Task, Note, etc.).
|
||||
|
||||
3. **Present findings**: Tell the user which custom objects were found. If none are found, inform the user that the workspace has no custom objects.
|
||||
|
||||
4. **Confirm before archiving**: List the custom objects you found and ask the user to confirm which ones they want to archive. Present them clearly with their name, label, and description.
|
||||
|
||||
5. **Archive confirmed objects**: Use update_many_object_metadata to set isActive to false on all confirmed objects in a single batch call.
|
||||
|
||||
6. **Report results**: After archiving is complete, summarize what was archived.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Only objects with isCustom = true can be archived. Standard objects cannot be archived through this skill.
|
||||
- Archiving an object hides it from the workspace but does not delete its fields, relations, or records.
|
||||
- When called directly by a user, confirm before archiving. When called by another skill (e.g. workspace-demo-seeding), proceed without confirmation.
|
||||
|
||||
## Approach
|
||||
|
||||
- Be clear about what will be archived and that it is reversible
|
||||
- If an object has relations to other objects, mention this before archiving
|
||||
- Archive all confirmed objects in a single batch call using update_many_object_metadata`,
|
||||
isCustom: false,
|
||||
},
|
||||
}),
|
||||
|
||||
pptx: (args: Omit<CreateStandardSkillArgs, 'context'>) =>
|
||||
createStandardSkillFlatMetadata({
|
||||
...args,
|
||||
|
||||
@@ -22,7 +22,9 @@ const addDashboardWidgetSchema = z.object({
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Required for GRAPH widgets: object UUID to aggregate'),
|
||||
.describe(
|
||||
'Required for GRAPH and RECORD_TABLE widgets: object UUID to aggregate or display',
|
||||
),
|
||||
configuration: widgetConfigurationSchema,
|
||||
});
|
||||
|
||||
@@ -36,7 +38,9 @@ export const createAddDashboardWidgetTool = (
|
||||
Use get_dashboard first to get pageLayoutTabId and existing widget positions.
|
||||
Use list_object_metadata_items to get objectMetadataId and field IDs for GRAPH widgets.
|
||||
|
||||
See create_complete_dashboard for configuration examples.`,
|
||||
For RECORD_TABLE widgets: create a dedicated view first with create_view (type TABLE), then pass its viewId in configuration. Never reuse an existing record index view.
|
||||
|
||||
See create_complete_dashboard for full configuration examples.`,
|
||||
inputSchema: addDashboardWidgetSchema,
|
||||
execute: async (parameters: {
|
||||
pageLayoutTabId: string;
|
||||
|
||||
+9
-1
@@ -25,7 +25,7 @@ const widgetSchema = z.object({
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe(
|
||||
'REQUIRED for GRAPH widgets: UUID of the object to aggregate (e.g., opportunity, company)',
|
||||
'REQUIRED for GRAPH and RECORD_TABLE widgets: UUID of the object to aggregate or display',
|
||||
),
|
||||
configuration: widgetConfigurationSchema,
|
||||
});
|
||||
@@ -82,6 +82,14 @@ WIDGET TYPES:
|
||||
|
||||
6. STANDALONE_RICH_TEXT: { type: "STANDALONE_RICH_TEXT", configuration: { configurationType: "STANDALONE_RICH_TEXT", body: { ... } } }
|
||||
|
||||
7. RECORD_TABLE: displays a live, filterable record list directly on the dashboard.
|
||||
- IMPORTANT: you MUST create a dedicated view for the widget BEFORE creating the widget. Use create_view to create a new TABLE view for the object, then pass its ID as viewId. Never reuse an existing index-page view — widget views and record index views must not overlap.
|
||||
- Requires: objectMetadataId (top-level, UUID of the object to display) AND configuration.viewId (UUID of the dedicated view you just created)
|
||||
- configuration.configurationType must be "RECORD_TABLE"
|
||||
- Recommended size: rowSpan 8-10, columnSpan 12 (full width)
|
||||
- Workflow: (1) call create_view with type TABLE for the object → get the viewId, (2) call create_many_view_fields to add visible columns to that view, (3) create the widget with that viewId
|
||||
- Example: { type: "RECORD_TABLE", objectMetadataId: "<object-uuid>", configuration: { configurationType: "RECORD_TABLE", viewId: "<dedicated-view-uuid>" } }
|
||||
|
||||
AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY`,
|
||||
inputSchema: createCompleteDashboardSchema,
|
||||
execute: async (parameters: {
|
||||
|
||||
@@ -191,6 +191,7 @@ export const widgetTypeSchema = z.enum([
|
||||
WidgetType.GRAPH,
|
||||
WidgetType.IFRAME,
|
||||
WidgetType.STANDALONE_RICH_TEXT,
|
||||
WidgetType.RECORD_TABLE,
|
||||
]);
|
||||
|
||||
// Graph configuration schema for AGGREGATE type (KPI numbers)
|
||||
@@ -380,6 +381,16 @@ const pieChartConfigSchema = withManualSortRefinement(
|
||||
}),
|
||||
);
|
||||
|
||||
// Record table configuration
|
||||
const recordTableConfigSchema = z.object({
|
||||
configurationType: z.literal(WidgetConfigurationType.RECORD_TABLE),
|
||||
viewId: z
|
||||
.uuid()
|
||||
.describe(
|
||||
'UUID of the dedicated view created for this widget. Must be created with create_view before creating the widget. Never reuse a record index view.',
|
||||
),
|
||||
});
|
||||
|
||||
// Iframe configuration
|
||||
const iframeConfigSchema = z.object({
|
||||
configurationType: z.literal(WidgetConfigurationType.IFRAME),
|
||||
@@ -439,6 +450,7 @@ export const widgetConfigurationSchema = z
|
||||
pieChartConfigSchema,
|
||||
iframeConfigSchema,
|
||||
richTextConfigSchema,
|
||||
recordTableConfigSchema,
|
||||
])
|
||||
.optional()
|
||||
.describe('Widget configuration - structure depends on widget type');
|
||||
@@ -451,6 +463,7 @@ export const widgetConfigurationSchemaWithoutDefaults = z
|
||||
pieChartConfigSchemaWithoutDefaults,
|
||||
iframeConfigSchema,
|
||||
richTextConfigSchema,
|
||||
recordTableConfigSchema,
|
||||
])
|
||||
.optional()
|
||||
.describe('Widget configuration - structure depends on widget type');
|
||||
|
||||
Reference in New Issue
Block a user