feat(ai): dashboard & view building (#22411)

## Why

Building a dashboard through AI chat used to cost ~9 sequential LLM
round-trips
(~160K input tokens for a single request): the agent had to resolve
object/field UUIDs and assemble views through many granular tool calls,
each
step replaying the full cached context.

## What changed

### 1. Reference objects & fields by name (fewer round-trips)
The agent no longer needs to resolve UUIDs before acting.
- `get_object_metadata`: filter by `objectName` (singular/plural) and a
new
`includeFields` flag returning each object's fields (`{id, name, type,
label}`)
  inline — object + field IDs in one call.
- `get_field_metadata`: accepts `objectName` as an alternative to
  `objectMetadataId`.
- All three dashboard write tools (`create_complete_dashboard`,
`add_dashboard_widget`, `update_dashboard_widget`): accept `objectName`
and
`*FieldName` variants (`aggregateFieldName`,
`primaryAxisGroupByFieldName`,
`secondaryAxisGroupByFieldName`, `groupByFieldName`, ratio `fieldName`),
resolved to UUIDs server-side by `resolveWidgetFieldNamesToIds`. UUID
variants
  still win when both are given.

### 2. `upsert_complete_view` — one atomic call to build/reconfigure a
view
- New `upsert_complete_view` tool + `ViewService.upsertCompleteView`:
create or
  update a view together with its fields, filters, and sorts.
- Children are **declarative**: a provided array replaces all existing
entries of
that kind, `[]` clears them, omitting leaves them untouched. Fields are
  referenced by name or UUID; no child-row IDs needed.
- Runs as a **single workspace migration** (`view` + `viewField` +
`viewFilter` +
`viewSort` in one `validateBuildAndRunWorkspaceMigration` matrice)
instead of
chained per-entity service calls. New
`buildCompleteViewChildrenFlatOperations`
  util assembles the child create/delete operations.
- Granular tools (`create_view_filter`, `update_view_sort`, …) are
retained for
  surgical single-entry edits.

### 3. Chart filters on dashboard widgets (end-to-end)
- Added `chartFilterSchema` (`recordFilters` + optional
`recordFilterGroups` for
AND/OR logic) to the four chart configs, with field-by-name or -UUID
references
  and documented operands/value formats.
- **Relative dates supported** — e.g. `PAST_7_DAY`, `THIS_1_MONTH`,
`NEXT_3_WEEK`,
plus open-ended `IS_IN_PAST` / `IS_IN_FUTURE` / `IS_TODAY`. Filters
route
through the same read pipeline (`computeRecordGqlOperationFilter`) as
view
  filters, so they resolve and apply correctly.
- `resolveChartFilterFieldNamesToIds` resolves filter `fieldName` → id
against the
  widget object.

### 4. Re-enable AI-assisted dashboards
- Removed the "coming soon" gating (`isActive: false` on the dashboard
skill and
the "not available yet" copy in the MCP server + chat prompts) and
registered
  `DashboardToolProvider`.
- Rewrote the dashboard skill prompt: confirmation gate (present a plan,
wait for
confirmation), completion guard (once confirmed, emit the create tool
in-turn —
no "now let me…" preambles), default-and-proceed (pick sensible defaults
for
missing fields instead of stalling), and an intent gate so informational
  dashboard questions are answered directly without loading skills.

### 5. Frontend: clearer advanced-filter labels
- `useRecordFilterField` now derives the filter label from field
metadata and
appends the relation target field (e.g. `Company → Name`), so
relation/target
filters — including those set by the AI — display correctly instead of
showing
  a stale/blank stored label.

## Fixes
- **`get_object_metadata({ objectName })` crash.**
`ObjectMetadataService.findManyWithinWorkspace`
  spread an array-form (`OR`) `where` into a plain object, producing
`{ "0": {...}, "1": {...}, workspaceId }` → `Property "0" was not found
in
"ObjectMetadataEntity"`. Now injects `workspaceId` into each OR clause,
so name
  lookups work.
- **Invalid SELECT/MULTI_SELECT filter options silently produced broken
charts/views.**
Chart-configuration validation and the migration-layer
`FlatViewFilterValidator`
now reject filters that reference options that don't exist, with a clear
  `Allowed values: …` message at creation time (shared
  `getInvalidSelectFilterOptionValues` util + tests).
- **Non-atomic view assembly.** The previous multi-call view build could
leave a
half-built view on failure; `upsert_complete_view` now runs as a single
transaction (one validation pass, one cache recompute, rollback on
error).
- **Blank RECORD_TABLE widgets from UNLISTED views.** Guidance + the
upsert
ownership check steer widget-backing views to `WORKSPACE` visibility; an
  UNLISTED view created without an owner renders a blank widget.
- **Extra discovery round-trip removed.** Deleted the skill→tool bundle
mechanism
(`SKILL_TOOL_BUNDLES`, `getBundledToolNamesForSkills`, and the
`load_skills`
  schema-loading path) that forced a second `learn_tools` call.
- **Type-safety of widget resolution.** Reworked the widget resolver to
build a
properly typed `WidgetWithMetadataIds` (dedicated input/output types)
instead of
  returning an untyped, cast-heavy object.

## Notes
- Backend changes are in `twenty-server`; one small `twenty-front`
change to the
  advanced-filter label hook. No entity/schema changes, so no migration.
- Tests added: `getInvalidSelectFilterOptionValues`,
`resolveWidgetFieldNamesToIds`
(incl. filter/relative-date resolution), `update_dashboard_widget`, and
expanded
  view-tools factory specs.
- Design decisions: dedicated composite tool over code-interpreter
orchestration
(atomicity + validation + consistency with `create_complete_dashboard` /
  `create_complete_workflow`); name-or-UUID but no child-row IDs on
`upsert_complete_view`; name→id resolution kept as stateless utils, not
services.

## Test plan
- [ ] `npx nx run twenty-server:typecheck`
- [ ] `npx nx lint:diff-with-main twenty-server` and `twenty-front`
- [ ] `npx nx test twenty-server` (view tools factory,
`getInvalidSelectFilterOptionValues`,
      `resolveWidgetFieldNamesToIds`, `update_dashboard_widget`)
- [ ] AI chat: "Create a dashboard with a chart of deal value by
pipeline stage
      and a table of the top 10 open opportunities" → plans, waits for
      confirmation, then builds with fewer round-trips
- [ ] AI chat: add a chart widget filtered by a relative date (e.g.
deals created
      in `PAST_7_DAY`) and confirm the chart is actually filtered
- [ ] Filter on a non-existent SELECT option is rejected with a clear
error

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22411?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-07-03 16:15:27 +02:00
committed by GitHub
parent 1a85b88d38
commit 1270054d35
40 changed files with 3013 additions and 162 deletions
@@ -20,9 +20,11 @@ Examples:
For simple CRUD operations (find/create/update/delete a record), you do NOT need a skill — but you still MUST call \`learn_tools\` first to learn the tool schema, then \`execute_tool\` to run it.
## Dashboards (coming soon)
## Dashboards
Building or editing dashboards through the AI is not available yet — it is a coming soon feature. If the user asks you to create, build, or modify a dashboard, do NOT attempt it: let them know that AI-assisted dashboards are coming soon, and offer the alternatives you can help with today (e.g. creating views, running analytics with \`group_by_*\`, or building workflows).
When the user asks to create, build, or modify a dashboard, load the \`dashboard-building\` skill and follow the Plan → Skill → Learn → Execute flow.
Intent gate: purely informational dashboard questions (e.g. "what is a dashboard in Twenty?", "how do I export a dashboard?", "can I share a dashboard with a client?") are NOT build requests. Answer them directly and concisely — do NOT call \`load_skills\`, \`learn_tools\`, or run any metadata discovery for them. Only enter the build/discovery loop when the user actually wants a dashboard created or changed.
## Skills vs Tools
@@ -47,6 +47,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
TokenModule,
WorkspaceCacheStorageModule,
FeatureFlagModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature([
@@ -4,12 +4,14 @@ import { type ToolSet } from 'ai';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { z } from 'zod';
import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant';
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { getObjectMetadataIdByName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const EXCLUDED_FIELD_NAMES = new Set(['searchVector', 'position', 'updatedBy']);
import { isDefined } from 'twenty-shared/utils';
const FIELD_STRIP_WHEN_NULLISH = [
'options',
@@ -26,16 +28,14 @@ const FIELD_STRIP_WHEN_FALSE = ['isLabelSyncedWithName'];
const FIELD_STRIP_WHEN_TRUE = ['isUIEditable'];
const GetFieldMetadataInputSchema = z.object({
id: z
id: z.uuid().optional().describe('Field ID. Returns one field if set.'),
objectMetadataId: z.uuid().optional().describe('Filter by object ID.'),
objectName: z
.string()
.uuid()
.optional()
.describe('Field ID. Returns one field if set.'),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe('Filter by object ID.'),
.describe(
'Filter by object name, singular or plural (e.g. "opportunity" or "opportunities"). Convenient alternative to objectMetadataId so you do not need to resolve the object id first.',
),
includeFullSystemFields: z
.boolean()
.default(false)
@@ -134,7 +134,36 @@ const CreateManyRelationFieldsInputSchema = z.object({
@Injectable()
export class FieldMetadataToolsFactory {
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
constructor(
private readonly fieldMetadataService: FieldMetadataService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
private async getObjectMetadataIdOrThrow(
workspaceId: string,
objectName: string,
): Promise<string> {
const { flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const objectMetadataId = getObjectMetadataIdByName({
flatObjectMetadataMaps,
objectName,
});
if (!isDefined(objectMetadataId)) {
throw new Error(
`Object "${objectName}" not found. Use get_object_metadata to list available objects.`,
);
}
return objectMetadataId;
}
generateTools(workspaceId: string): ToolSet {
return {
@@ -145,16 +174,26 @@ export class FieldMetadataToolsFactory {
execute: async (parameters: {
id?: string;
objectMetadataId?: string;
objectName?: string;
includeFullSystemFields?: boolean;
limit?: number;
}) => {
const objectMetadataId =
parameters.objectMetadataId ??
(parameters.objectName
? await this.getObjectMetadataIdOrThrow(
workspaceId,
parameters.objectName,
)
: undefined);
const rawResults = await this.fieldMetadataService.query({
filter: {
workspaceId: { eq: workspaceId },
...(parameters.id ? { id: { eq: parameters.id } } : {}),
...(parameters.objectMetadataId
...(isDefined(objectMetadataId)
? {
objectMetadataId: { eq: parameters.objectMetadataId },
objectMetadataId: { eq: objectMetadataId },
}
: {}),
},
@@ -164,7 +203,10 @@ export class FieldMetadataToolsFactory {
const compactedFields = (
rawResults as unknown as Record<string, unknown>[]
)
.filter((field) => !EXCLUDED_FIELD_NAMES.has(field.name as string))
.filter(
(field) =>
!METADATA_TOOL_EXCLUDED_FIELD_NAMES.has(field.name as string),
)
.map((field) => {
if (field.isSystem && !parameters.includeFullSystemFields) {
return {
@@ -0,0 +1,103 @@
import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getInvalidSelectFilterOptionValues } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util';
const OBJECT_ID = '11111111-1111-4111-8111-111111111111';
const selectField = getFlatFieldMetadataMock({
id: '33333333-3333-4333-8333-333333333333',
universalIdentifier: '33333333-3333-4333-8333-333333333333',
objectMetadataId: OBJECT_ID,
type: FieldMetadataType.SELECT,
name: 'stage',
label: 'Stage',
options: [
{ id: 'opt-won', color: 'green', label: 'Won', value: 'WON', position: 0 },
{ id: 'opt-lost', color: 'red', label: 'Lost', value: 'LOST', position: 1 },
],
}) as FlatFieldMetadata<FieldMetadataType.SELECT>;
describe('getInvalidSelectFilterOptionValues', () => {
it('returns invalid values for a SELECT field when an option does not exist', () => {
expect(
getInvalidSelectFilterOptionValues({
fieldMetadata: selectField,
operand: ViewFilterOperand.IS,
value: ['WON', 'NOT_A_REAL_OPTION'],
}),
).toEqual(['NOT_A_REAL_OPTION']);
});
it('returns an empty array when all SELECT values are valid options', () => {
expect(
getInvalidSelectFilterOptionValues({
fieldMetadata: selectField,
operand: ViewFilterOperand.IS,
value: ['WON', 'LOST'],
}),
).toEqual([]);
});
it('supports a plain string array value', () => {
expect(
getInvalidSelectFilterOptionValues({
fieldMetadata: selectField,
operand: ViewFilterOperand.IS,
value: ['NOPE'],
}),
).toEqual(['NOPE']);
});
it('parses a JSON-stringified array value (chart filter format)', () => {
expect(
getInvalidSelectFilterOptionValues({
fieldMetadata: selectField,
operand: ViewFilterOperand.IS,
value: '["WON"]',
}),
).toEqual([]);
});
it('parses a JSON-stringified multi-value array and reports invalid options', () => {
expect(
getInvalidSelectFilterOptionValues({
fieldMetadata: selectField,
operand: ViewFilterOperand.IS,
value: '["WON","NOPE"]',
}),
).toEqual(['NOPE']);
});
it('treats a non-JSON plain string as a single option value', () => {
expect(
getInvalidSelectFilterOptionValues({
fieldMetadata: selectField,
operand: ViewFilterOperand.IS,
value: 'WON',
}),
).toEqual([]);
});
it('ignores value-less operands like IS_EMPTY', () => {
expect(
getInvalidSelectFilterOptionValues({
fieldMetadata: selectField,
operand: ViewFilterOperand.IS_EMPTY,
value: '',
}),
).toEqual([]);
});
it('ignores subfield filters', () => {
expect(
getInvalidSelectFilterOptionValues({
fieldMetadata: selectField,
operand: ViewFilterOperand.IS,
subFieldName: 'someSubField',
value: JSON.stringify(['NOPE']),
}),
).toEqual([]);
});
});
@@ -0,0 +1,4 @@
export const buildFieldByObjectIdAndNameKey = (
objectMetadataId: string,
fieldName: string,
): string => `${objectMetadataId}:${fieldName}`;
@@ -0,0 +1,38 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldByObjectIdAndNameKey } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util';
export const buildFieldIdByNameMaps = (
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): {
fieldIdByObjectIdAndName: Map<string, string>;
fieldById: Map<string, { type: FieldMetadataType }>;
} => {
const fieldIdByObjectIdAndName = new Map<string, string>();
const fieldById = new Map<string, { type: FieldMetadataType }>();
for (const fieldMetadata of Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
)) {
if (!isDefined(fieldMetadata)) {
continue;
}
fieldIdByObjectIdAndName.set(
buildFieldByObjectIdAndNameKey(
fieldMetadata.objectMetadataId,
fieldMetadata.name,
),
fieldMetadata.id,
);
fieldById.set(fieldMetadata.id, {
type: fieldMetadata.type,
});
}
return { fieldIdByObjectIdAndName, fieldById };
};
@@ -0,0 +1,72 @@
import { isArray, isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType, type ViewFilterOperand } from 'twenty-shared/types';
import {
isDefined,
isRecordFilterOperandExpectingValue,
} from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
const normalizeSelectFilterValues = (
value: ViewFilterValue | null,
): string[] => {
if (isArray(value)) {
return value.filter(isNonEmptyString);
}
if (!isNonEmptyString(value)) {
return [];
}
try {
const parsedValue: unknown = JSON.parse(value);
if (isArray(parsedValue)) {
return parsedValue.filter(isNonEmptyString);
}
} catch {
// Not a JSON-stringified array — treat the raw string as a single value.
}
return [value];
};
export const getInvalidSelectFilterOptionValues = ({
fieldMetadata,
operand,
subFieldName,
value,
}: {
fieldMetadata: Pick<
FlatFieldMetadata<
FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT
>,
'type' | 'options'
>;
operand: ViewFilterOperand;
subFieldName?: string | null;
value: ViewFilterValue | null;
}): string[] => {
if (!isRecordFilterOperandExpectingValue(operand)) {
return [];
}
if (isNonEmptyString(subFieldName)) {
return [];
}
const filterValues = normalizeSelectFilterValues(value);
if (filterValues.length === 0) {
return [];
}
if (!isDefined(fieldMetadata.options)) {
return filterValues;
}
return filterValues.filter((filterValue) =>
fieldMetadata.options.every((option) => option.value !== filterValue),
);
};
@@ -0,0 +1,17 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
export const getObjectMetadataIdByName = ({
flatObjectMetadataMaps,
objectName,
}: {
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
objectName: string;
}): string | undefined => {
const { idByNameSingular, idByNamePlural } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
return idByNameSingular[objectName] ?? idByNamePlural[objectName];
};
@@ -0,0 +1,161 @@
import {
type ViewFilterOperand,
type ViewSortDirection,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type FlatEntityToCreateDeleteUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-to-create-delete-update.type';
import { fromCreateViewFieldInputToFlatViewFieldToCreate } from 'src/engine/metadata-modules/flat-view-field/utils/from-create-view-field-input-to-flat-view-field-to-create.util';
import { fromCreateViewFilterInputToFlatViewFilterToCreate } from 'src/engine/metadata-modules/flat-view-filter/utils/from-create-view-filter-input-to-flat-view-filter-to-create.util';
import { fromCreateViewSortInputToFlatViewSortToCreate } from 'src/engine/metadata-modules/flat-view-sort/utils/from-create-view-sort-input-to-flat-view-sort-to-create.util';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
// Resolved children specs (field names already resolved to fieldMetadataId)
export type CompleteViewFieldSpec = {
fieldMetadataId: string;
isVisible: boolean;
size: number;
};
export type CompleteViewFilterSpec = {
fieldMetadataId: string;
operand: ViewFilterOperand;
value: ViewFilterValue;
subFieldName?: string;
};
export type CompleteViewSortSpec = {
fieldMetadataId: string;
direction: ViewSortDirection;
};
type BuildCompleteViewChildrenFlatOperationsArgs = {
viewId: string;
flatApplication: FlatApplication;
fields?: CompleteViewFieldSpec[];
filters?: CompleteViewFilterSpec[];
sorts?: CompleteViewSortSpec[];
} & Pick<
AllFlatEntityMaps,
| 'flatFieldMetadataMaps'
| 'flatViewMaps'
| 'flatViewFieldMaps'
| 'flatViewFilterMaps'
| 'flatViewSortMaps'
| 'flatViewFieldGroupMaps'
| 'flatViewFilterGroupMaps'
>;
type CompleteViewChildrenFlatOperations = {
viewField?: FlatEntityToCreateDeleteUpdate<'viewField'>;
viewFilter?: FlatEntityToCreateDeleteUpdate<'viewFilter'>;
viewSort?: FlatEntityToCreateDeleteUpdate<'viewSort'>;
};
export const buildCompleteViewChildrenFlatOperations = ({
viewId,
flatApplication,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFieldMaps,
flatViewFilterMaps,
flatViewSortMaps,
flatViewFieldGroupMaps,
flatViewFilterGroupMaps,
fields,
filters,
sorts,
}: BuildCompleteViewChildrenFlatOperationsArgs): CompleteViewChildrenFlatOperations => {
const operations: CompleteViewChildrenFlatOperations = {};
if (isDefined(fields)) {
const existingFlatViewFields = Object.values(
flatViewFieldMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((flatViewField) => flatViewField.viewId === viewId);
const flatViewFieldsToCreate = fields.map((field, index) =>
fromCreateViewFieldInputToFlatViewFieldToCreate({
createViewFieldInput: {
viewId,
fieldMetadataId: field.fieldMetadataId,
isVisible: field.isVisible,
size: field.size,
position: index,
},
flatApplication,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFieldGroupMaps,
}),
);
operations.viewField = {
flatEntityToCreate: flatViewFieldsToCreate,
flatEntityToDelete: existingFlatViewFields,
flatEntityToUpdate: [],
};
}
if (isDefined(filters)) {
const existingFlatViewFilters = Object.values(
flatViewFilterMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((flatViewFilter) => flatViewFilter.viewId === viewId);
const flatViewFiltersToCreate = filters.map((filter) =>
fromCreateViewFilterInputToFlatViewFilterToCreate({
createViewFilterInput: {
viewId,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
subFieldName: filter.subFieldName,
},
flatApplication,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFilterGroupMaps,
}),
);
operations.viewFilter = {
flatEntityToCreate: flatViewFiltersToCreate,
flatEntityToDelete: existingFlatViewFilters,
flatEntityToUpdate: [],
};
}
if (isDefined(sorts)) {
const existingFlatViewSorts = Object.values(
flatViewSortMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((flatViewSort) => flatViewSort.viewId === viewId);
const flatViewSortsToCreate = sorts.map((sort) =>
fromCreateViewSortInputToFlatViewSortToCreate({
createViewSortInput: {
viewId,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
},
flatApplication,
flatFieldMetadataMaps,
flatViewMaps,
}),
);
operations.viewSort = {
flatEntityToCreate: flatViewSortsToCreate,
flatEntityToDelete: existingFlatViewSorts,
flatEntityToUpdate: [],
};
}
return operations;
};
@@ -9,8 +9,8 @@ import {
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -48,6 +48,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
FeatureFlagModule,
ApplicationModule,
ApplicationTranslationModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
NestjsQueryGraphQLModule.forFeature({
imports: [
TypeORMModule,
@@ -989,12 +989,19 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
workspaceId: string,
options?: FindManyOptions<ObjectMetadataEntity>,
): Promise<FlatObjectMetadata[]> {
const whereWithWorkspaceId = Array.isArray(options?.where)
? options.where.map((whereCondition) => ({
...whereCondition,
workspaceId,
}))
: {
...options?.where,
workspaceId,
};
const objectMetadataEntities = await this.objectMetadataRepository.find({
...options,
where: {
...options?.where,
workspaceId,
},
where: whereWithWorkspaceId,
order: {
...options?.order,
},
@@ -1,14 +1,25 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant';
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
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 { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
type InlinedObjectFieldSummary = {
id: string;
name: string;
type: FieldMetadataType;
label: string;
};
const OBJECT_STRIP_WHEN_NULLISH = [
'overrides',
'color',
@@ -20,11 +31,19 @@ const OBJECT_STRIP_WHEN_NULLISH = [
];
const GetObjectMetadataInputSchema = z.object({
id: z
id: z.uuid().optional().describe('Object ID. Returns one object if set.'),
objectName: z
.string()
.uuid()
.optional()
.describe('Object ID. Returns one object if set.'),
.describe(
'Filter by object name, singular or plural (e.g. "opportunity" or "opportunities"). Lets you locate an object by name in one call without scanning the full list.',
),
includeFields: z
.boolean()
.default(false)
.describe(
'When true, each returned object includes its fields as a compact array of {id, name, type, label}. Use this to fetch an object and all the field ids you need in a single call (e.g. before building a dashboard) instead of a separate get_field_metadata call.',
),
includeFullSystemObjects: z
.boolean()
.default(false)
@@ -103,16 +122,59 @@ const UpdateManyObjectMetadataInputSchema = z.object({
@Injectable()
export class ObjectMetadataToolsFactory {
constructor(private readonly objectMetadataService: ObjectMetadataService) {}
constructor(
private readonly objectMetadataService: ObjectMetadataService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
private async buildFieldsByObjectId(
workspaceId: string,
): Promise<Map<string, InlinedObjectFieldSummary[]>> {
const { flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const fieldsByObjectId = new Map<string, InlinedObjectFieldSummary[]>();
for (const fieldMetadata of Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
)) {
if (
!isDefined(fieldMetadata) ||
METADATA_TOOL_EXCLUDED_FIELD_NAMES.has(fieldMetadata.name)
) {
continue;
}
const existing =
fieldsByObjectId.get(fieldMetadata.objectMetadataId) ?? [];
existing.push({
id: fieldMetadata.id,
name: fieldMetadata.name,
type: fieldMetadata.type,
label: fieldMetadata.label ?? fieldMetadata.name,
});
fieldsByObjectId.set(fieldMetadata.objectMetadataId, existing);
}
return fieldsByObjectId;
}
generateTools(workspaceId: string): ToolSet {
return {
get_object_metadata: {
description:
"List object metadata as an array. System objects are returned as compact {id, nameSingular, namePlural} — enough to locate an object by name and read its id. Keep includeFullSystemObjects at its default (false); only set it true when you specifically need a system object's full configuration.",
"List object metadata as an array. Filter to a single object by id or objectName (singular or plural). Set includeFields to also return each object's fields ({id, name, type, label}) — enough to build a dashboard or view without a separate get_field_metadata call. System objects are otherwise returned as compact {id, nameSingular, namePlural}. Keep includeFullSystemObjects at its default (false); only set it true when you specifically need a system object's full configuration.",
inputSchema: GetObjectMetadataInputSchema,
execute: async (parameters: {
id?: string;
objectName?: string;
includeFields?: boolean;
includeFullSystemObjects?: boolean;
limit?: number;
}) => {
@@ -120,25 +182,44 @@ export class ObjectMetadataToolsFactory {
await this.objectMetadataService.findManyWithinWorkspace(
workspaceId,
{
...(parameters.id ? { where: { id: parameters.id } } : {}),
...(parameters.id
? { where: { id: parameters.id } }
: parameters.objectName
? {
where: [
{ nameSingular: parameters.objectName },
{ namePlural: parameters.objectName },
],
}
: {}),
take: parameters.limit ?? 100,
},
);
const fieldsByObjectId = parameters.includeFields
? await this.buildFieldsByObjectId(workspaceId)
: undefined;
return flatObjectMetadatas.map((flatObjectMetadata) => {
const dto =
fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata);
const fields = fieldsByObjectId?.get(dto.id) ?? [];
if (dto.isSystem && !parameters.includeFullSystemObjects) {
return {
id: dto.id,
nameSingular: dto.nameSingular,
namePlural: dto.namePlural,
...(parameters.includeFields ? { fields } : {}),
};
}
return compactMetadataOutput(
{ ...dto },
{
...dto,
...(parameters.includeFields ? { fields } : {}),
},
{ stripWhenNullish: OBJECT_STRIP_WHEN_NULLISH },
);
});
@@ -1,16 +1,23 @@
import { msg } from '@lingui/core/macro';
import {
FieldMetadataType,
type ChartRecordFilter,
type ViewFilterOperand,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getInvalidSelectFilterOptionValues } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { PageLayoutWidgetFieldValidationException } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget-field-validation.exception';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { PageLayoutWidgetFieldValidationException } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget-field-validation.exception';
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { findActiveFlatFieldMetadataById } from 'src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util';
import { isChartReferencingFieldInConfiguration } from 'src/engine/metadata-modules/page-layout-widget/utils/is-chart-referencing-field-in-configuration.util';
@@ -47,6 +54,45 @@ const validateGroupByFieldAsChartFieldOrThrow = (
}
};
const validateSelectFilterOptionsOrThrow = ({
recordFilter,
filterField,
widgetTitle,
}: {
recordFilter: ChartRecordFilter;
filterField: FlatFieldMetadata<
FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT
>;
widgetTitle?: string | null;
}): void => {
if (!isDefined(recordFilter.value)) {
return;
}
const invalidValues = getInvalidSelectFilterOptionValues({
fieldMetadata: filterField,
operand: recordFilter.operand as ViewFilterOperand,
value: recordFilter.value,
});
if (invalidValues.length === 0) {
return;
}
const invalidValuesText = invalidValues
.map((value) => `"${value}"`)
.join(', ');
const allowedValuesText = filterField.options
?.map((option) => option.value)
.map((optionValue) => `"${optionValue}"`)
.join(', ');
throw buildChartFieldValidationException(
`Filter on "${filterField.label}" uses option(s) ${invalidValuesText} that do not exist. Allowed values: ${allowedValuesText}.`,
widgetTitle,
);
};
export const validateChartConfigurationFieldReferencesOrThrow = ({
widgetConfiguration,
widgetObjectMetadataId,
@@ -211,6 +257,17 @@ export const validateChartConfigurationFieldReferencesOrThrow = ({
widgetTitle,
);
}
if (
isFlatFieldMetadataOfType(filterField, FieldMetadataType.SELECT) ||
isFlatFieldMetadataOfType(filterField, FieldMetadataType.MULTI_SELECT)
) {
validateSelectFilterOptionsOrThrow({
recordFilter,
filterField,
widgetTitle,
});
}
}
}
};
@@ -10,6 +10,9 @@ import {
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 { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
import { CompleteViewUpsertService } from 'src/engine/metadata-modules/view/tools/services/complete-view-upsert.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';
@@ -17,9 +20,9 @@ import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-to
describe('ViewToolsFactory', () => {
let viewToolsFactory: ViewToolsFactory;
let viewService: jest.Mocked<ViewService>;
let completeViewUpsertService: jest.Mocked<CompleteViewUpsertService>;
let viewFieldService: jest.Mocked<ViewFieldService>;
let viewQueryParamsService: jest.Mocked<ViewQueryParamsService>;
let _flatEntityMapsCacheService: jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>;
const mockWorkspaceId = 'workspace-id';
const mockUserWorkspaceId = 'user-workspace-id';
@@ -99,15 +102,40 @@ describe('ViewToolsFactory', () => {
findByWorkspaceId: jest.fn(),
findByObjectMetadataId: jest.fn(),
findById: jest.fn(),
findByIdWithRelations: jest.fn(),
createOne: jest.fn(),
updateOne: jest.fn(),
deleteOne: jest.fn(),
},
},
{
provide: CompleteViewUpsertService,
useValue: {
upsertCompleteView: jest.fn(),
},
},
{
provide: ViewFieldService,
useValue: {
createMany: jest.fn().mockResolvedValue([]),
findByViewId: jest.fn().mockResolvedValue([]),
deleteOne: jest.fn(),
},
},
{
provide: ViewFilterService,
useValue: {
createOne: jest.fn(),
findByViewId: jest.fn().mockResolvedValue([]),
deleteOne: jest.fn(),
},
},
{
provide: ViewSortService,
useValue: {
createOne: jest.fn(),
findByViewId: jest.fn().mockResolvedValue([]),
deleteOne: jest.fn(),
},
},
{
@@ -130,11 +158,9 @@ describe('ViewToolsFactory', () => {
viewToolsFactory = module.get<ViewToolsFactory>(ViewToolsFactory);
viewService = module.get(ViewService);
completeViewUpsertService = module.get(CompleteViewUpsertService);
viewFieldService = module.get(ViewFieldService);
viewQueryParamsService = module.get(ViewQueryParamsService);
_flatEntityMapsCacheService = module.get(
WorkspaceManyOrAllFlatEntityMapsCacheService,
);
});
it('should be defined', () => {
@@ -579,6 +605,239 @@ describe('ViewToolsFactory', () => {
});
});
describe('upsert_complete_view tool', () => {
it('should be generated alongside the other write tools', () => {
const tools = viewToolsFactory.generateWriteTools(mockWorkspaceId);
expect(tools).toHaveProperty('upsert_complete_view');
expect(tools['upsert_complete_view']).toHaveProperty('description');
expect(tools['upsert_complete_view']).toHaveProperty('inputSchema');
expect(tools['upsert_complete_view']).toHaveProperty('execute');
});
it('should create a view with fields, filters, and sorts referenced by name in a single upsert call', async () => {
completeViewUpsertService.upsertCompleteView.mockResolvedValue({
id: 'new-view-id',
name: 'Pipeline',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
icon: 'IconList',
visibility: ViewVisibility.WORKSPACE,
viewFields: [{}, {}],
viewFilters: [{}],
viewSorts: [{}],
} as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
const result = await callExecute(tools['upsert_complete_view'], {
objectNameSingular: mockObjectNameSingular,
name: 'Pipeline',
type: ViewType.TABLE,
fields: [{ fieldName: 'name' }, { fieldName: 'stage' }],
filters: [{ fieldName: 'stage', operand: 'IS_NOT', value: ['WON'] }],
sorts: [{ fieldName: 'name', direction: 'DESC' }],
});
expect(viewService.createOne).not.toHaveBeenCalled();
expect(
completeViewUpsertService.upsertCompleteView,
).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: mockWorkspaceId,
userWorkspaceId: mockUserWorkspaceId,
existingViewId: undefined,
objectMetadataId: mockObjectMetadataId,
name: 'Pipeline',
type: ViewType.TABLE,
fields: [
{
fieldMetadataId: mockNameFieldMetadataId,
isVisible: true,
size: 150,
},
{
fieldMetadataId: mockStageFieldMetadataId,
isVisible: true,
size: 150,
},
],
filters: [
{
fieldMetadataId: mockStageFieldMetadataId,
operand: 'IS_NOT',
value: ['WON'],
subFieldName: undefined,
},
],
sorts: [
{ fieldMetadataId: mockNameFieldMetadataId, direction: 'DESC' },
],
}),
);
expect(result).toEqual({
id: 'new-view-id',
name: 'Pipeline',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
icon: 'IconList',
visibility: ViewVisibility.WORKSPACE,
fieldCount: 2,
filterCount: 1,
sortCount: 1,
});
});
it('should accept a field referenced by fieldMetadataId without name resolution', async () => {
completeViewUpsertService.upsertCompleteView.mockResolvedValue({
id: 'new-view-id',
name: 'By Id',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
icon: 'IconList',
visibility: ViewVisibility.WORKSPACE,
viewFields: [{}],
} as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await callExecute(tools['upsert_complete_view'], {
objectNameSingular: mockObjectNameSingular,
name: 'By Id',
fields: [{ fieldMetadataId: mockStageFieldMetadataId }],
});
expect(
completeViewUpsertService.upsertCompleteView,
).toHaveBeenCalledWith(
expect.objectContaining({
fields: [
{
fieldMetadataId: mockStageFieldMetadataId,
isVisible: true,
size: 150,
},
],
}),
);
});
it('should require objectNameSingular when creating', async () => {
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await expect(
callExecute(tools['upsert_complete_view'], {
name: 'Missing object',
fields: [{ fieldName: 'name' }],
}),
).rejects.toThrow('objectNameSingular is required');
});
it('should delegate filter replacement to upsertCompleteView when updating', async () => {
const existingView = {
...mockView,
visibility: ViewVisibility.WORKSPACE,
};
viewService.findById.mockResolvedValue(existingView as any);
completeViewUpsertService.upsertCompleteView.mockResolvedValue({
...existingView,
viewFilters: [{}],
} as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await callExecute(tools['upsert_complete_view'], {
id: mockViewId,
filters: [{ fieldName: 'stage', operand: 'IS', value: ['WON'] }],
});
expect(
completeViewUpsertService.upsertCompleteView,
).toHaveBeenCalledWith(
expect.objectContaining({
existingViewId: mockViewId,
objectMetadataId: mockObjectMetadataId,
filters: [
{
fieldMetadataId: mockStageFieldMetadataId,
operand: 'IS',
value: ['WON'],
subFieldName: undefined,
},
],
}),
);
expect(viewService.createOne).not.toHaveBeenCalled();
});
it('should pass an empty sorts array through to upsertCompleteView on update', async () => {
const existingView = {
...mockView,
visibility: ViewVisibility.WORKSPACE,
};
viewService.findById.mockResolvedValue(existingView as any);
completeViewUpsertService.upsertCompleteView.mockResolvedValue({
...existingView,
viewSorts: [],
} as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await callExecute(tools['upsert_complete_view'], {
id: mockViewId,
sorts: [],
});
expect(
completeViewUpsertService.upsertCompleteView,
).toHaveBeenCalledWith(
expect.objectContaining({
existingViewId: mockViewId,
sorts: [],
}),
);
});
it('should reject updating another users unlisted view', async () => {
const existingView = {
...mockView,
visibility: ViewVisibility.UNLISTED,
createdByUserWorkspaceId: 'other-user-workspace-id',
};
viewService.findById.mockResolvedValue(existingView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await expect(
callExecute(tools['upsert_complete_view'], {
id: mockViewId,
name: 'Updated',
}),
).rejects.toThrow('You can only update your own unlisted views');
});
});
describe('delete-view tool', () => {
it('should delete a workspace view', async () => {
const existingView = {
@@ -0,0 +1,374 @@
import { Injectable } from '@nestjs/common';
import { t } from '@lingui/core/macro';
import {
AggregateOperations,
ViewCalendarLayout,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type FlatEntityToCreateDeleteUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-to-create-delete-update.type';
import {
buildCompleteViewChildrenFlatOperations,
type CompleteViewFieldSpec,
type CompleteViewFilterSpec,
type CompleteViewSortSpec,
} from 'src/engine/metadata-modules/flat-view/utils/build-complete-view-children-flat-operations.util';
import { fromCreateViewInputToFlatViewToCreate } from 'src/engine/metadata-modules/flat-view/utils/from-create-view-input-to-flat-view-to-create.util';
import { fromUpdateViewInputToFlatViewToUpdateOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-update-view-input-to-flat-view-to-update-or-throw.util';
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import {
ViewException,
ViewExceptionCode,
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
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 { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
type ViewUpsertFlatEntityOperations = {
view?: FlatEntityToCreateDeleteUpdate<'view'>;
viewGroup?: FlatEntityToCreateDeleteUpdate<'viewGroup'>;
viewField?: FlatEntityToCreateDeleteUpdate<'viewField'>;
viewFilter?: FlatEntityToCreateDeleteUpdate<'viewFilter'>;
viewSort?: FlatEntityToCreateDeleteUpdate<'viewSort'>;
};
type ViewUpsertRootOperations = Pick<
ViewUpsertFlatEntityOperations,
'view' | 'viewGroup'
> & {
viewId: string;
viewUniversalIdentifier?: string;
};
// Backs the AI `upsert_complete_view` tool: upserts a view together with its
// fields, filters and sorts in a single workspace migration.
@Injectable()
export class CompleteViewUpsertService {
constructor(
private readonly viewService: ViewService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
async upsertCompleteView({
workspaceId,
userWorkspaceId,
existingViewId,
objectMetadataId,
name,
icon,
type,
visibility,
mainGroupByFieldMetadataId,
kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId,
calendarLayout,
calendarFieldMetadataId,
fields,
filters,
sorts,
}: {
workspaceId: string;
userWorkspaceId?: string;
existingViewId?: string;
objectMetadataId?: string;
name?: string;
icon?: string;
type?: ViewType;
visibility?: ViewVisibility;
mainGroupByFieldMetadataId?: string;
kanbanAggregateOperation?: AggregateOperations;
kanbanAggregateOperationFieldMetadataId?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldMetadataId?: string;
fields?: CompleteViewFieldSpec[];
filters?: CompleteViewFilterSpec[];
sorts?: CompleteViewSortSpec[];
}): Promise<ViewDTO> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const applicationUniversalIdentifier =
workspaceCustomFlatApplication.universalIdentifier;
const {
flatFieldMetadataMaps,
flatObjectMetadataMaps,
flatViewMaps,
flatViewGroupMaps,
flatViewFieldMaps,
flatViewFilterMaps,
flatViewSortMaps,
flatViewFieldGroupMaps,
flatViewFilterGroupMaps,
} =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
'flatViewMaps',
'flatViewGroupMaps',
'flatViewFieldMaps',
'flatViewFilterMaps',
'flatViewSortMaps',
'flatViewFieldGroupMaps',
'flatViewFilterGroupMaps',
],
},
);
const isCreatingView = !isDefined(existingViewId);
const rootOperations = isDefined(existingViewId)
? this.buildUpdateViewRootOperationsOrThrow({
existingViewId,
name,
icon,
userWorkspaceId,
applicationUniversalIdentifier,
flatViewMaps,
flatViewGroupMaps,
flatFieldMetadataMaps,
})
: this.buildCreateViewRootOperationsOrThrow({
objectMetadataId,
name,
icon,
type,
visibility,
mainGroupByFieldMetadataId,
kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId,
calendarLayout,
calendarFieldMetadataId,
userWorkspaceId,
flatApplication: workspaceCustomFlatApplication,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
const { viewId, viewUniversalIdentifier } = rootOperations;
const flatViewMapsForChildren =
isCreatingView && isDefined(viewUniversalIdentifier)
? {
...flatViewMaps,
universalIdentifierById: {
...flatViewMaps.universalIdentifierById,
[viewId]: viewUniversalIdentifier,
},
}
: flatViewMaps;
const childrenOperations = buildCompleteViewChildrenFlatOperations({
viewId,
flatApplication: workspaceCustomFlatApplication,
flatFieldMetadataMaps,
flatViewMaps: flatViewMapsForChildren,
flatViewFieldMaps,
flatViewFilterMaps,
flatViewSortMaps,
flatViewFieldGroupMaps,
flatViewFilterGroupMaps,
fields,
filters,
sorts,
});
const allFlatEntityOperationByMetadataName: ViewUpsertFlatEntityOperations =
{
...(isDefined(rootOperations.view)
? { view: rootOperations.view }
: {}),
...(isDefined(rootOperations.viewGroup)
? { viewGroup: rootOperations.viewGroup }
: {}),
...(isDefined(childrenOperations.viewField)
? { viewField: childrenOperations.viewField }
: {}),
...(isDefined(childrenOperations.viewFilter)
? { viewFilter: childrenOperations.viewFilter }
: {}),
...(isDefined(childrenOperations.viewSort)
? { viewSort: childrenOperations.viewSort }
: {}),
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName,
workspaceId,
isSystemBuild: false,
applicationUniversalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while upserting complete view',
);
}
const view = await this.viewService.findByIdWithRelations(
viewId,
workspaceId,
);
if (!isDefined(view)) {
throw new ViewException(
t`View not found after upsert`,
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
return view;
}
private buildCreateViewRootOperationsOrThrow({
objectMetadataId,
name,
icon,
type,
visibility,
mainGroupByFieldMetadataId,
kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId,
calendarLayout,
calendarFieldMetadataId,
userWorkspaceId,
flatApplication,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
}: {
objectMetadataId?: string;
name?: string;
icon?: string;
type?: ViewType;
visibility?: ViewVisibility;
mainGroupByFieldMetadataId?: string;
kanbanAggregateOperation?: AggregateOperations;
kanbanAggregateOperationFieldMetadataId?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldMetadataId?: string;
userWorkspaceId?: string;
flatApplication: FlatApplication;
flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps'];
flatObjectMetadataMaps: AllFlatEntityMaps['flatObjectMetadataMaps'];
}): ViewUpsertRootOperations {
if (!isDefined(objectMetadataId)) {
throw new ViewException(
t`ObjectMetadataId is required when creating a view`,
ViewExceptionCode.INVALID_VIEW_DATA,
);
}
const { flatViewToCreate, flatViewGroupsToCreate } =
fromCreateViewInputToFlatViewToCreate({
createViewInput: {
name: name ?? 'Untitled view',
objectMetadataId,
icon: icon ?? 'IconList',
type: type ?? ViewType.TABLE,
visibility: visibility ?? ViewVisibility.WORKSPACE,
mainGroupByFieldMetadataId,
kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId,
calendarLayout,
calendarFieldMetadataId,
},
createdByUserWorkspaceId: userWorkspaceId,
flatApplication,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
viewId: flatViewToCreate.id,
viewUniversalIdentifier: flatViewToCreate.universalIdentifier,
view: {
flatEntityToCreate: [flatViewToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
...(flatViewGroupsToCreate.length > 0
? {
viewGroup: {
flatEntityToCreate: flatViewGroupsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
}
: {}),
};
}
private buildUpdateViewRootOperationsOrThrow({
existingViewId,
name,
icon,
userWorkspaceId,
applicationUniversalIdentifier,
flatViewMaps,
flatViewGroupMaps,
flatFieldMetadataMaps,
}: {
existingViewId: string;
name?: string;
icon?: string;
userWorkspaceId?: string;
applicationUniversalIdentifier: string;
flatViewMaps: AllFlatEntityMaps['flatViewMaps'];
flatViewGroupMaps: AllFlatEntityMaps['flatViewGroupMaps'];
flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps'];
}): ViewUpsertRootOperations {
if (!isDefined(name) && !isDefined(icon)) {
return { viewId: existingViewId };
}
const { flatViewToUpdate, flatViewGroupsToDelete, flatViewGroupsToCreate } =
fromUpdateViewInputToFlatViewToUpdateOrThrow({
updateViewInput: { id: existingViewId, name, icon },
flatViewMaps,
flatViewGroupMaps,
flatFieldMetadataMaps,
userWorkspaceId,
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
});
return {
viewId: existingViewId,
view: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatViewToUpdate],
},
...(flatViewGroupsToCreate.length > 0 || flatViewGroupsToDelete.length > 0
? {
viewGroup: {
flatEntityToCreate: flatViewGroupsToCreate,
flatEntityToDelete: flatViewGroupsToDelete,
flatEntityToUpdate: [],
},
}
: {}),
};
}
}
@@ -5,6 +5,8 @@ import {
AggregateOperations,
FieldMetadataType,
ViewCalendarLayout,
ViewFilterOperand,
ViewSortDirection,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
@@ -12,13 +14,19 @@ 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 { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { CompleteViewUpsertService } from 'src/engine/metadata-modules/view/tools/services/complete-view-upsert.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 { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { isFieldMetadataDateKind, isNonEmptyArray } from 'twenty-shared/utils';
import {
isDefined,
isFieldMetadataDateKind,
isNonEmptyArray,
} from 'twenty-shared/utils';
const GetViewsInputSchema = z.object({
objectNameSingular: z
@@ -64,7 +72,9 @@ const CreateViewInputSchema = z.object({
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
.optional()
.default(ViewVisibility.WORKSPACE)
.describe('View visibility'),
.describe(
'View visibility. ALWAYS prefer WORKSPACE (the default) — it is the right choice for shared views and for any view backing a dashboard widget, so the view is visible to everyone. Only use UNLISTED for a private, personal view explicitly requested by a single user, AND only when a user identity is available. An UNLISTED view created without an owner becomes invisible to everyone (e.g. its dashboard widget renders blank), so never use UNLISTED for widget-backing views.',
),
mainGroupByFieldName: z
.string()
.optional()
@@ -115,10 +125,160 @@ const DeleteViewInputSchema = z.object({
id: z.string().uuid().describe('View ID to delete'),
});
const VIEW_FILTER_OPERAND_OPTIONS = Object.values(ViewFilterOperand);
const VIEW_SORT_DIRECTION_OPTIONS = Object.values(ViewSortDirection);
const fieldReferenceShape = {
fieldName: z
.string()
.optional()
.describe(
'Field name (e.g. "amount", "stage"). Resolved to a UUID server-side. Provide this or fieldMetadataId.',
),
fieldMetadataId: z
.uuid()
.optional()
.describe(
'Field UUID. Alternative to fieldName; takes precedence when both are given.',
),
};
const UpsertCompleteViewFieldSchema = z.object({
...fieldReferenceShape,
isVisible: z
.boolean()
.optional()
.default(true)
.describe('Whether the column is visible. Defaults to true.'),
size: z
.number()
.int()
.optional()
.default(150)
.describe('Column width. Defaults to 150.'),
});
const UpsertCompleteViewFilterSchema = z.object({
...fieldReferenceShape,
operand: z
.enum(VIEW_FILTER_OPERAND_OPTIONS)
.describe(
'Filter operator. Must be valid for the field type (e.g. SELECT: IS, IS_NOT; CURRENCY: GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL; TEXT: CONTAINS).',
),
value: z
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string(), z.unknown()),
])
.describe(
'Filter value. Array of option values for SELECT/MULTI_SELECT (e.g. ["WON"]), number for NUMBER/CURRENCY, "" for IS_EMPTY/IS_NOT_EMPTY.',
),
subFieldName: z
.string()
.optional()
.describe(
'Required for composite fields (e.g. "amountMicros" for CURRENCY, "addressCity" for ADDRESS, "firstName" for FULL_NAME).',
),
});
const UpsertCompleteViewSortSchema = z.object({
...fieldReferenceShape,
direction: z
.enum(VIEW_SORT_DIRECTION_OPTIONS)
.optional()
.default(ViewSortDirection.ASC)
.describe('Sort direction: ASC or DESC. Defaults to ASC.'),
});
const UpsertCompleteViewInputSchema = z.object({
id: z
.uuid()
.optional()
.describe('View ID to update. Omit to create a new view.'),
objectNameSingular: z
.string()
.optional()
.describe(
'Object name this view is for (e.g. "opportunity"). Required when creating (no id); ignored when id is given.',
),
name: z.string().optional().describe('View name'),
icon: z.string().optional().describe('Icon identifier (e.g. "IconList")'),
type: z
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
.optional()
.describe('View type. Defaults to TABLE on create.'),
visibility: z
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
.optional()
.describe(
'View visibility. Defaults to WORKSPACE on create, which is almost always the best fit — it makes the view visible to everyone and is REQUIRED for any view backing a dashboard widget. Only set UNLISTED for a private, personal view a specific user explicitly asked for, AND only when a user identity is available. An UNLISTED view created without an owner is invisible to everyone (its dashboard widget renders blank), so never use UNLISTED for widget-backing views.',
),
mainGroupByFieldName: z
.string()
.optional()
.describe(
'Field name to group by (required for KANBAN, must be a SELECT field, e.g. "stage").',
),
kanbanAggregateOperation: z
.enum(Object.values(AggregateOperations) as [string, ...string[]])
.optional()
.describe('Aggregate operation for kanban columns (e.g. "SUM", "COUNT").'),
kanbanAggregateOperationFieldName: z
.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).'),
calendarFieldName: z
.string()
.optional()
.describe(
'Date field name for the calendar (required for CALENDAR, must be DATE or DATE_TIME).',
),
fields: z
.array(UpsertCompleteViewFieldSchema)
.optional()
.describe(
'Declarative list of columns, in display order. Provided array REPLACES all existing fields; [] clears them; omit to leave untouched.',
),
filters: z
.array(UpsertCompleteViewFilterSchema)
.optional()
.describe(
'Declarative list of filters. Provided array REPLACES all existing filters; [] clears them; omit to leave untouched.',
),
sorts: z
.array(UpsertCompleteViewSortSchema)
.optional()
.describe(
'Declarative list of sorts. Provided array REPLACES all existing sorts; [] clears them; omit to leave untouched.',
),
});
type FieldReference = { fieldName?: string; fieldMetadataId?: string };
type UpsertCompleteViewIdentifiers = {
existingViewId?: string;
objectMetadataId: string;
mainGroupByFieldMetadataId?: string;
kanbanAggregateOperationFieldMetadataId?: string;
calendarFieldMetadataId?: string;
};
@Injectable()
export class ViewToolsFactory {
constructor(
private readonly viewService: ViewService,
private readonly completeViewUpsertService: CompleteViewUpsertService,
private readonly viewFieldService: ViewFieldService,
private readonly viewQueryParamsService: ViewQueryParamsService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
@@ -253,6 +413,138 @@ export class ViewToolsFactory {
return fieldMetadata.id;
}
private async getFieldMetadataIdOrThrow(
workspaceId: string,
objectMetadataId: string,
reference: FieldReference,
): Promise<string> {
if (isDefined(reference.fieldMetadataId)) {
return reference.fieldMetadataId;
}
if (isDefined(reference.fieldName)) {
return this.resolveFieldMetadataId(
workspaceId,
objectMetadataId,
reference.fieldName,
);
}
throw new Error(
'Each field, filter, and sort entry must provide either fieldName or fieldMetadataId.',
);
}
private async resolveUpsertCompleteViewIdentifiersOrThrow({
parameters,
workspaceId,
userWorkspaceId,
}: {
parameters: {
id?: string;
objectNameSingular?: string;
type?: ViewType;
mainGroupByFieldName?: string;
kanbanAggregateOperationFieldName?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldName?: string;
};
workspaceId: string;
userWorkspaceId?: string;
}): Promise<UpsertCompleteViewIdentifiers> {
if (isDefined(parameters.id)) {
const existingView = await this.viewService.findById(
parameters.id,
workspaceId,
);
if (!existingView) {
throw new Error(`View with id ${parameters.id} not found`);
}
if (
existingView.visibility === ViewVisibility.UNLISTED &&
existingView.createdByUserWorkspaceId !== userWorkspaceId
) {
throw new Error('You can only update your own unlisted views');
}
return {
existingViewId: existingView.id,
objectMetadataId: existingView.objectMetadataId,
};
}
if (!isDefined(parameters.objectNameSingular)) {
throw new Error(
'objectNameSingular is required when creating a view (no id provided).',
);
}
const objectMetadataId = await this.resolveObjectMetadataId(
workspaceId,
parameters.objectNameSingular,
);
if (
parameters.type === ViewType.KANBAN &&
!isDefined(parameters.mainGroupByFieldName)
) {
throw new Error(
'KANBAN views require mainGroupByFieldName. Provide a SELECT field name (e.g. "stage").',
);
}
if (parameters.type === ViewType.CALENDAR) {
if (!isDefined(parameters.calendarFieldName)) {
throw new Error(
'CALENDAR views require calendarFieldName (a DATE or DATE_TIME field name).',
);
}
if (!isDefined(parameters.calendarLayout)) {
throw new Error(
'CALENDAR views require calendarLayout. Provide one of: "DAY", "WEEK", "MONTH".',
);
}
}
const mainGroupByFieldMetadataId = isDefined(
parameters.mainGroupByFieldName,
)
? await this.resolveGroupByFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.mainGroupByFieldName,
)
: undefined;
const kanbanAggregateOperationFieldMetadataId = isDefined(
parameters.kanbanAggregateOperationFieldName,
)
? await this.resolveFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.kanbanAggregateOperationFieldName,
)
: undefined;
const calendarFieldMetadataId = isDefined(parameters.calendarFieldName)
? await this.resolveCalendarFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.calendarFieldName,
)
: undefined;
return {
objectMetadataId,
mainGroupByFieldMetadataId,
kanbanAggregateOperationFieldMetadataId,
calendarFieldMetadataId,
};
}
generateReadTools(
workspaceId: string,
userWorkspaceId?: string,
@@ -317,6 +609,139 @@ export class ViewToolsFactory {
generateWriteTools(workspaceId: string, userWorkspaceId?: string): ToolSet {
return {
upsert_complete_view: {
description: `Create or update a complete view — the view plus its fields (columns), filters, and sorts — in a single call.
IDENTITY: Omit "id" to CREATE a new view (requires objectNameSingular). Provide "id" to UPDATE an existing view.
FIELD REFERENCES: In fields/filters/sorts you can reference a field by NAME (fieldName, e.g. "amount") or by UUID (fieldMetadataId). Names are resolved server-side, so you usually do NOT need get_field_metadata first. UUID wins when both are given.
DECLARATIVE CHILDREN (replace semantics): fields, filters, and sorts each describe the FULL desired set.
- A provided array REPLACES all existing entries of that kind (existing ones are deleted, the new ones created in order).
- An empty array [] CLEARS all entries of that kind.
- Omitting the key leaves existing entries untouched.
This means you never need to fetch child ids to edit a view — just pass the desired end state. For surgical single-entry edits, the granular tools (create_view_filter, update_view_sort, etc.) remain available.
VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT field), CALENDAR (requires calendarFieldName + calendarLayout).`,
inputSchema: UpsertCompleteViewInputSchema,
execute: async (parameters: {
id?: string;
objectNameSingular?: string;
name?: string;
icon?: string;
type?: ViewType;
visibility?: ViewVisibility;
mainGroupByFieldName?: string;
kanbanAggregateOperation?: AggregateOperations;
kanbanAggregateOperationFieldName?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldName?: string;
fields?: Array<
FieldReference & { isVisible?: boolean; size?: number }
>;
filters?: Array<
FieldReference & {
operand: ViewFilterOperand;
value: ViewFilterValue;
subFieldName?: string;
}
>;
sorts?: Array<FieldReference & { direction?: ViewSortDirection }>;
}) => {
try {
const {
existingViewId,
objectMetadataId,
mainGroupByFieldMetadataId,
kanbanAggregateOperationFieldMetadataId,
calendarFieldMetadataId,
} = await this.resolveUpsertCompleteViewIdentifiersOrThrow({
parameters,
workspaceId,
userWorkspaceId,
});
const fields = isDefined(parameters.fields)
? await Promise.all(
parameters.fields.map(async (field) => ({
fieldMetadataId: await this.getFieldMetadataIdOrThrow(
workspaceId,
objectMetadataId,
field,
),
isVisible: field.isVisible ?? true,
size: field.size ?? 150,
})),
)
: undefined;
const filters = isDefined(parameters.filters)
? await Promise.all(
parameters.filters.map(async (filter) => ({
fieldMetadataId: await this.getFieldMetadataIdOrThrow(
workspaceId,
objectMetadataId,
filter,
),
operand: filter.operand,
value: filter.value,
subFieldName: filter.subFieldName,
})),
)
: undefined;
const sorts = isDefined(parameters.sorts)
? await Promise.all(
parameters.sorts.map(async (sort) => ({
fieldMetadataId: await this.getFieldMetadataIdOrThrow(
workspaceId,
objectMetadataId,
sort,
),
direction: sort.direction ?? ViewSortDirection.ASC,
})),
)
: undefined;
const view =
await this.completeViewUpsertService.upsertCompleteView({
workspaceId,
userWorkspaceId,
existingViewId,
objectMetadataId,
name: parameters.name,
icon: parameters.icon,
type: parameters.type,
visibility: parameters.visibility,
mainGroupByFieldMetadataId,
kanbanAggregateOperation: parameters.kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId,
calendarLayout: parameters.calendarLayout,
calendarFieldMetadataId,
fields,
filters,
sorts,
});
return {
id: view.id,
name: view.name,
objectMetadataId,
type: view.type,
icon: view.icon,
visibility: view.visibility,
fieldCount: view.viewFields?.length ?? 0,
filterCount: view.viewFilters?.length ?? 0,
sortCount: view.viewSorts?.length ?? 0,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
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"). For CALENDAR views, calendarFieldName and calendarLayout are required.',
@@ -12,6 +12,7 @@ import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-fie
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 { CompleteViewUpsertService } from 'src/engine/metadata-modules/view/tools/services/complete-view-upsert.service';
import { ViewWidgetUpsertService } from 'src/engine/metadata-modules/view/services/view-widget-upsert.service';
import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@@ -43,6 +44,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
controllers: [ViewController],
providers: [
ViewService,
CompleteViewUpsertService,
ViewResolver,
ViewQueryParamsService,
ViewToolsFactory,