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
@@ -21,6 +21,9 @@ export const useRecordFilterField = (recordFilterId: string) => {
recordFilter?.fieldMetadataId ?? '',
);
const { fieldMetadataItem: relationTargetFieldMetadataItem } =
useFieldMetadataItemById(recordFilter?.relationTargetFieldMetadataId ?? '');
const { getIcon } = useIcons();
const icon = isDefined(fieldMetadataItem?.icon)
@@ -38,9 +41,15 @@ export const useRecordFilterField = (recordFilterId: string) => {
)
: '';
const fieldLabel = fieldMetadataItem?.label ?? '';
const baseLabel = isDefined(relationTargetFieldMetadataItem)
? `${fieldLabel}${relationTargetFieldMetadataItem.label}`
: fieldLabel;
const label = isNonEmptyString(subFieldLabel)
? `${recordFilter?.label} / ${subFieldLabel}`
: (recordFilter?.label ?? '');
? `${baseLabel} / ${subFieldLabel}`
: baseLabel;
return {
label,
@@ -38,10 +38,11 @@ export const buildMcpServerInstructions = (
` For complex tasks (workflows, metadata), load the matching skill BEFORE calling tools.`,
` ⚠️ Never call workflow or metadata tools without loading their skill first.`,
``,
`Dashboards (coming soon):`,
` Building or editing dashboards through the AI is not available yet — it is a coming soon feature.`,
` If asked to create/build/modify a dashboard, do not attempt it: say AI-assisted dashboards are coming soon,`,
` and offer alternatives (create views, run analytics with group_by_{objects}, or build workflows).`,
`Dashboards:`,
` To create/build/modify a dashboard, load the dashboard-building skill first, then resolve metadata and build.`,
` Once metadata is resolved, emit create_complete_dashboard in the same turn — never stop on a "now let me…" preamble.`,
` If a referenced field is missing (e.g. lead source, won/lost stage), pick a sensible default and state the assumption instead of stalling.`,
` Informational dashboard questions ("what is a dashboard?") are NOT build requests — answer directly, do not load skill.`,
``,
`Route by intent:`,
` Named entity ("Acme company") → find_many_{objects} to resolve id first, then operate on id`,
@@ -0,0 +1,5 @@
export const METADATA_TOOL_EXCLUDED_FIELD_NAMES = new Set([
'searchVector',
'position',
'updatedBy',
]);
@@ -58,11 +58,12 @@ export class ViewToolProvider implements ToolProvider {
private async buildToolSet(context: ToolProviderContext): Promise<ToolSet> {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const userWorkspaceId = context.userWorkspaceId;
const readTools = {
...this.viewToolsFactory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
userWorkspaceId,
workspaceMemberId ?? undefined,
),
...this.viewFieldToolsFactory.generateReadTools(context.workspaceId),
@@ -84,7 +85,7 @@ export class ViewToolProvider implements ToolProvider {
const writeTools = {
...this.viewToolsFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
userWorkspaceId,
),
...this.viewFieldToolsFactory.generateWriteTools(context.workspaceId),
...this.viewFilterToolsFactory.generateWriteTools(context.workspaceId),
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module';
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider';
import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/providers/dashboard-tool.provider';
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider';
import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider';
@@ -68,6 +69,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
ToolIndexResolver,
ToolExecutorService,
ActionToolProvider,
DashboardToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
NavigationMenuItemToolProvider,
@@ -90,6 +92,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
viewProvider: ViewToolProvider,
webhookProvider: WebhookToolProvider,
workflowProvider: WorkflowToolProvider,
dashboardProvider: DashboardToolProvider,
) => [
actionProvider,
databaseProvider,
@@ -99,6 +102,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
viewProvider,
webhookProvider,
workflowProvider,
dashboardProvider,
],
inject: [
ActionToolProvider,
@@ -109,6 +113,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
ViewToolProvider,
WebhookToolProvider,
WorkflowToolProvider,
DashboardToolProvider,
],
},
ToolRegistryService,
@@ -58,7 +58,7 @@ export const createLoadSkillTool = (
label: skill.label,
content: skill.content,
})),
message: `Loaded ${skills.map((skill) => skill.label).join(', ')}`,
message: `Loaded ${skills.map((skill) => skill.label).join(', ')}.`,
};
},
});
@@ -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,
@@ -425,14 +425,38 @@ You help users create and manage dashboards with widgets.
- add_dashboard_tab, add_dashboard_widget, update_dashboard_widget, delete_dashboard_widget
- get_object_metadata / get_field_metadata (resolve object + field IDs)
## Graph Widget Workflow
## Confirmation gate (ALWAYS ask before creating or updating)
1. Ask what data the user wants to visualize.
2. Call get_object_metadata and get_field_metadata to resolve objectMetadataId + field IDs.
3. Always call get_dashboard before modifying widgets.
4. Build the widget configuration using the rules below.
5. Call add_dashboard_widget or update_dashboard_widget. Use activeTabId from context if available.
6. Call get_dashboard to verify the final configuration.
Before calling ANY tool that creates or modifies a dashboard (\`create_complete_dashboard\`, \`add_dashboard_tab\`, \`add_dashboard_widget\`, \`update_dashboard_widget\`, \`delete_dashboard_widget\`), you MUST first present a short plan and get explicit user confirmation.
- Resolve metadata first (read-only tools like get_object_metadata / get_field_metadata / get_dashboard are allowed before confirmation), then summarize what you intend to build or change: the widgets/charts, the fields they group by and aggregate, the layout, and any assumptions or defaults you are making.
- Ask the user to confirm (or adjust) and then STOP and wait for their answer. Do NOT call any creation/update tool in the same turn as the plan.
- Only after the user confirms do you proceed to build/modify in the next turn.
- Keep the plan concise — a few bullets, not an essay. The goal is a quick "yes, go ahead" or a correction, not a lengthy back-and-forth.
## Build Workflow (creating a new dashboard, AFTER confirmation)
Once the user has confirmed the plan, your job is to deliver the dashboard in that turn.
1. Resolve metadata in as few calls as possible: call get_object_metadata and get_field_metadata for the relevant object(s) once, and batch field lookups. Do NOT re-fetch metadata you already have in context.
2. Build the full widget configuration using the rules below.
3. **Emit \`create_complete_dashboard\` with ALL widgets in a single call.** Prefer one-shot \`create_complete_dashboard\` over building the dashboard incrementally with multiple \`add_dashboard_widget\` calls — fewer round-trips means lower cost and fewer points to stall.
4. After the tool returns success, confirm to the user what was built (and restate any assumptions you made).
### Completion guard (critical, applies once confirmed)
- After the user has confirmed, you MUST call the appropriate dashboard tool for the requested change in that turn (e.g. \`create_complete_dashboard\`, \`add_dashboard_widget\`, \`update_dashboard_widget\`, or \`delete_dashboard_widget\`). **Never end your turn on a "now let me…" / "I'll build this…" preamble without actually calling the tool.** A preamble with no following tool call (after confirmation) is a failure.
- Do NOT yield or hand back to the user until at least one dashboard tool has returned success — unless you are still waiting on confirmation or genuinely blocked on something only the user can answer.
### Default-and-proceed (resolve defaults in the plan, do not stall)
- If the request references a field or concept that does not exist in the workspace (e.g. "Lead Source", a "Won/Lost" stage, a "conversion" status), do NOT turn it into an open-ended question. Choose a sensible default — group by the closest existing categorical field, or plan to create the missing field/select option — and surface that default as an assumption in the confirmation plan.
- Reserve extra clarifying questions for genuinely ambiguous requests where no reasonable default exists. Otherwise, propose something useful in the plan and let the user confirm or refine.
## Modifying an existing dashboard
- Call get_dashboard first to read the current layout, then present the intended changes and get confirmation (see the confirmation gate above) before calling add_dashboard_widget / update_dashboard_widget / delete_dashboard_widget. Use activeTabId from context if available.
- Only call get_dashboard when modifying — never before creating a brand-new dashboard.
## Field Resolution Rules
@@ -483,13 +507,15 @@ You help users create and manage dashboards with widgets.
- 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 46 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)
- **MANDATORY: create the dedicated view in ONE call before creating the widget**:
- Call \`upsert_complete_view\` once with the view plus its fields, filters, and sorts. Do NOT use the separate create_view / create_many_view_fields / create_many_view_filters / create_view_sort calls — that is several round-trips where one suffices.
- Reference fields by NAME (fieldName) — you generally do not need field UUIDs. Pass fieldMetadataId only if you already have it.
- Include 46 of the most relevant fields (label identifier + key SELECT/DATE/CURRENCY fields). Order in the array IS the column order.
- Add filters/sorts to focus the table (e.g. filter out DONE/CANCELLED records, sort by a date field).
- Never reuse a record index view — widget views and record index views must be separate
- Leave the view's visibility as WORKSPACE (the default) — never set UNLISTED on a widget-backing view, or the widget will render a blank table
- Set objectMetadataId on the widget (top-level, required)
- Set configuration.viewId to the UUID of the dedicated view (required)
- Set configuration.viewId to the UUID returned by upsert_complete_view (required)
- columnSpan 12 (full width) or 6 (half width), rowSpan 610
Example (STANDALONE_RICH_TEXT):
@@ -498,12 +524,16 @@ 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 } }
Example (RECORD_TABLE — one view call, then the widget):
Step 1 — upsert_complete_view: {
"name": "Active Repairs",
"objectNameSingular": "repair",
"type": "TABLE",
"fields": [{ "fieldName": "name" }, { "fieldName": "status" }, { "fieldName": "amount" }],
"filters": [{ "fieldName": "status", "operand": "IS_NOT", "value": ["DONE"] }],
"sorts": [{ "fieldName": "createdAt", "direction": "DESC" }]
} → { "id": "<view-uuid>" }
Step 2 — 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
@@ -528,10 +558,6 @@ After creating a tab, use its returned tabId as pageLayoutTabId when calling add
- 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,
// Dashboard tools are temporarily disabled in AI chat / MCP because the
// generated dashboards are not reliable yet. Keeping the skill defined
// (inactive) so it can be re-enabled once the tooling is trustworthy.
isActive: false,
},
}),
@@ -1268,16 +1294,27 @@ You help users create and configure views to organize how they see their records
## Tools
- **upsert_complete_view** - Create OR update a view together with its fields, filters, and sorts in a single call. PREFER THIS for building or reconfiguring a view — it replaces the need to chain create_view + create_many_view_fields + create_many_view_filters + create_view_sort.
- get_views - List existing views (filter by object name)
- create_view - Create a new view
- update_view - Update view name/icon
- create_view - Create a new view (low-level; prefer upsert_complete_view)
- update_view - Update view name/icon (low-level; prefer upsert_complete_view)
- delete_view - Delete a view
- create_many_view_fields - Add visible columns to a view
- create_many_view_fields - Add visible columns to a view (low-level; prefer upsert_complete_view)
- update_many_view_fields - Update column configuration
- get_view_fields - List columns in a view
- get_object_metadata / get_field_metadata - Discover objects and their fields
- navigate_app - Navigate to a view after creation
## upsert_complete_view (preferred)
One call builds or reconfigures an entire view:
- Omit \`id\` to CREATE (requires \`objectNameSingular\`); provide \`id\` to UPDATE an existing view.
- Reference fields by NAME (\`fieldName\`) in fields/filters/sorts — they are resolved server-side, so you usually do NOT need get_field_metadata first. You may pass \`fieldMetadataId\` instead when you already have the UUID.
- \`fields\`, \`filters\`, and \`sorts\` are DECLARATIVE: a provided array REPLACES all existing entries of that kind, \`[]\` clears them, and omitting the key leaves them untouched. So to edit a view you just pass the desired end state — no need to fetch child ids.
- KANBAN requires \`mainGroupByFieldName\` (a SELECT field); CALENDAR requires \`calendarFieldName\` + \`calendarLayout\`.
Example: { "objectNameSingular": "opportunity", "type": "KANBAN", "name": "Pipeline", "mainGroupByFieldName": "stage", "kanbanAggregateOperation": "SUM", "kanbanAggregateOperationFieldName": "amount", "fields": [{ "fieldName": "name" }, { "fieldName": "amount" }, { "fieldName": "stage" }], "sorts": [{ "fieldName": "amount", "direction": "DESC" }] }
## Workflow
1. **Identify the target object**: If the user didn't specify which object, ask them. Present available objects and explain what each holds:
@@ -1293,14 +1330,12 @@ You help users create and configure views to organize how they see their records
- 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: You must provide both \`calendarFieldName\` (a DATE/DATE_TIME field name) and \`calendarLayout\` ("DAY", "WEEK", or "MONTH") when calling create_view.
- For TABLE: No special configuration needed.
3. **Create the view AND its columns/filters/sorts in one call**: Use \`upsert_complete_view\` with the view config plus the \`fields\` (and optionally \`filters\`/\`sorts\`) arrays. Reference fields by name.
- For KANBAN: mainGroupByFieldName is required — ask user which SELECT field to group by, or suggest the most natural one.
- For CALENDAR: provide both \`calendarFieldName\` (a DATE/DATE_TIME field name) and \`calendarLayout\` ("DAY", "WEEK", or "MONTH").
- For TABLE: No special configuration needed beyond the fields list.
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.
4. **Navigate**: Use navigate_app to show the user their new view.
## KANBAN Best Practices
@@ -1349,8 +1384,9 @@ You help users add filters and sorts to their views so they see the most relevan
- get_views - List existing views to find the one to modify
- get_view_query_parameters - Check existing filters and sorts on a view
- get_field_metadata - 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
- **upsert_complete_view** - Replace ALL of a view's filters and/or sorts in one call (pass \`id\` + the desired \`filters\`/\`sorts\` arrays, referencing fields by name). Prefer this when setting the full filter/sort set at once.
- create_view_filter / create_many_view_filters - Add individual filters to a view (use for surgical single-filter edits)
- create_view_sort / create_many_view_sorts - Add individual sorts to a view (use for surgical single-sort edits)
- navigate_app - Navigate to the view to show results
## Filter Operators by Field Type
@@ -14,7 +14,11 @@ import {
} from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.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 { ViewFilterExceptionCode } from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type';
@@ -110,6 +114,27 @@ export class FlatViewFilterValidatorService {
if (isDefined(incompatibleOperandError)) {
validationResult.errors.push(incompatibleOperandError);
}
if (
isFieldMetadataEntityOfType(
referencedFieldMetadata,
FieldMetadataType.SELECT,
) ||
isFieldMetadataEntityOfType(
referencedFieldMetadata,
FieldMetadataType.MULTI_SELECT,
)
) {
const invalidSelectOptionError = this.getInvalidSelectOptionError({
referencedFieldMetadata,
operand: flatViewFilterToValidate.operand,
value: flatViewFilterToValidate.value,
});
if (isDefined(invalidSelectOptionError)) {
validationResult.errors.push(invalidSelectOptionError);
}
}
}
if (
@@ -247,6 +272,30 @@ export class FlatViewFilterValidatorService {
if (isDefined(incompatibleOperandError)) {
validationResult.errors.push(incompatibleOperandError);
}
if (
('value' in flatEntityUpdate ||
'fieldMetadataUniversalIdentifier' in flatEntityUpdate ||
'operand' in flatEntityUpdate) &&
(isFieldMetadataEntityOfType(
referencedFieldMetadata,
FieldMetadataType.SELECT,
) ||
isFieldMetadataEntityOfType(
referencedFieldMetadata,
FieldMetadataType.MULTI_SELECT,
))
) {
const invalidSelectOptionError = this.getInvalidSelectOptionError({
referencedFieldMetadata,
operand: updatedFlatViewFilter.operand,
value: updatedFlatViewFilter.value,
});
if (isDefined(invalidSelectOptionError)) {
validationResult.errors.push(invalidSelectOptionError);
}
}
}
if (isDefined(updatedFlatViewFilter.viewFilterGroupUniversalIdentifier)) {
@@ -268,6 +317,42 @@ export class FlatViewFilterValidatorService {
return validationResult;
}
private getInvalidSelectOptionError({
referencedFieldMetadata,
operand,
value,
}: {
referencedFieldMetadata: Pick<
FlatFieldMetadata<
FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT
>,
'type' | 'options' | 'label'
>;
operand: ViewFilterOperand;
value: ViewFilterValue;
}) {
const invalidValues = getInvalidSelectFilterOptionValues({
fieldMetadata: referencedFieldMetadata,
operand,
value,
});
if (invalidValues.length === 0) {
return undefined;
}
const invalidValuesText = invalidValues.join(', ');
const allowedValuesText = referencedFieldMetadata.options
?.map((option) => option.value)
.join(', ');
return {
code: ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA,
message: t`Filter on "${referencedFieldMetadata.label}" uses option(s) ${invalidValuesText} that do not exist. Allowed values: ${allowedValuesText}.`,
userFriendlyMessage: msg`Filter uses a select option that does not exist`,
};
}
private getIncompatibleOperandError({
operand,
fieldType,
@@ -0,0 +1,177 @@
import { AggregateOperations, ViewFilterOperand } from 'twenty-shared/types';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { type DashboardToolDependencies } from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
import { createUpdateDashboardWidgetTool } from 'src/modules/dashboard/tools/update-dashboard-widget.tool';
const WORKSPACE_ID = '20202020-aaaa-4d02-bf25-6aeccf7ea419';
const WIDGET_ID = '20202020-ffff-4d02-bf25-6aeccf7ea419';
const OPPORTUNITY_OBJECT_ID = '20202020-dddd-4d02-bf25-6aeccf7ea419';
const AMOUNT_FIELD_ID = '20202020-bbbb-4d02-bf25-6aeccf7ea419';
const STAGE_FIELD_ID = '20202020-cccc-4d02-bf25-6aeccf7ea419';
const flatObjectMetadataMaps = {
byUniversalIdentifier: {
'object-opportunity': {
id: OPPORTUNITY_OBJECT_ID,
nameSingular: 'opportunity',
namePlural: 'opportunities',
},
},
};
const flatFieldMetadataMaps = {
byUniversalIdentifier: {
'field-amount': {
id: AMOUNT_FIELD_ID,
name: 'amount',
label: 'Amount',
objectMetadataId: OPPORTUNITY_OBJECT_ID,
isActive: true,
type: 'CURRENCY',
},
'field-stage': {
id: STAGE_FIELD_ID,
name: 'stage',
label: 'Stage',
objectMetadataId: OPPORTUNITY_OBJECT_ID,
isActive: true,
type: 'SELECT',
},
},
};
const buildDeps = () => ({
pageLayoutWidgetService: {
findByIdOrThrow: jest
.fn()
.mockResolvedValue({ objectMetadataId: OPPORTUNITY_OBJECT_ID }),
update: jest.fn().mockImplementation(async ({ updateData }) => ({
id: WIDGET_ID,
title: 'Widget',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
configuration: updateData.configuration,
})),
},
flatEntityMapsCacheService: {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
},
});
const createTool = (deps: ReturnType<typeof buildDeps>) =>
createUpdateDashboardWidgetTool(
deps as unknown as Pick<
DashboardToolDependencies,
'pageLayoutWidgetService' | 'flatEntityMapsCacheService'
>,
{ workspaceId: WORKSPACE_ID },
);
describe('update_dashboard_widget tool', () => {
it('resolves filter field names using the existing widget object when the object is not changed', async () => {
const deps = buildDeps();
const tool = createTool(deps);
const result = await tool.execute({
widgetId: WIDGET_ID,
configuration: {
configurationType: WidgetConfigurationType.AGGREGATE_CHART,
aggregateFieldName: 'amount',
aggregateOperation: AggregateOperations.SUM,
displayDataLabel: false,
filter: {
recordFilters: [
{
fieldName: 'stage',
operand: ViewFilterOperand.IS,
value: '["WON"]',
},
],
},
},
});
expect(result.success).toBe(true);
expect(deps.pageLayoutWidgetService.findByIdOrThrow).toHaveBeenCalledWith({
id: WIDGET_ID,
workspaceId: WORKSPACE_ID,
});
const updateData = deps.pageLayoutWidgetService.update.mock.calls[0][0]
.updateData as {
configuration: {
aggregateFieldMetadataId: string;
filter: { recordFilters: Array<Record<string, unknown>> };
};
};
expect(updateData.configuration.aggregateFieldMetadataId).toBe(
AMOUNT_FIELD_ID,
);
expect(updateData.configuration.filter.recordFilters[0]).toMatchObject({
fieldMetadataId: STAGE_FIELD_ID,
operand: ViewFilterOperand.IS,
});
expect(updateData.configuration.filter.recordFilters[0]).not.toHaveProperty(
'fieldName',
);
});
it('resolves objectName to a UUID and uses it to resolve field names', async () => {
const deps = buildDeps();
const tool = createTool(deps);
const result = await tool.execute({
widgetId: WIDGET_ID,
objectName: 'opportunities',
configuration: {
configurationType: WidgetConfigurationType.AGGREGATE_CHART,
aggregateFieldName: 'amount',
aggregateOperation: AggregateOperations.SUM,
displayDataLabel: false,
},
});
expect(result.success).toBe(true);
expect(deps.pageLayoutWidgetService.findByIdOrThrow).not.toHaveBeenCalled();
const updateData = deps.pageLayoutWidgetService.update.mock.calls[0][0]
.updateData as {
objectMetadataId: string;
objectName?: string;
configuration: { aggregateFieldMetadataId: string };
};
expect(updateData.objectMetadataId).toBe(OPPORTUNITY_OBJECT_ID);
expect(updateData).not.toHaveProperty('objectName');
expect(updateData.configuration.aggregateFieldMetadataId).toBe(
AMOUNT_FIELD_ID,
);
});
it('does not resolve identifiers when only non-field properties change', async () => {
const deps = buildDeps();
const tool = createTool(deps);
const result = await tool.execute({
widgetId: WIDGET_ID,
title: 'Renamed widget',
});
expect(result.success).toBe(true);
expect(
deps.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps,
).not.toHaveBeenCalled();
expect(deps.pageLayoutWidgetService.findByIdOrThrow).not.toHaveBeenCalled();
const updateData = deps.pageLayoutWidgetService.update.mock.calls[0][0]
.updateData as Record<string, unknown>;
expect(updateData).toEqual({ title: 'Renamed widget' });
});
});
@@ -2,7 +2,6 @@ import { z } from 'zod';
import { type CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/create-page-layout-widget.input';
import { type WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import {
gridPositionSchema,
widgetConfigurationSchema,
@@ -12,6 +11,9 @@ import {
type DashboardToolContext,
type DashboardToolDependencies,
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
import { type WidgetConfigurationInput } from 'src/modules/dashboard/tools/types/widget-configuration-input.type';
import { computeDashboardIdentifierMaps } from 'src/modules/dashboard/tools/utils/compute-dashboard-identifier-maps.util';
import { resolveWidgetFieldNamesToIds } from 'src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util';
const addDashboardWidgetSchema = z.object({
pageLayoutTabId: z.string().uuid().describe('Tab UUID from get_dashboard'),
@@ -19,26 +21,36 @@ const addDashboardWidgetSchema = z.object({
type: widgetTypeSchema.describe('Widget type'),
gridPosition: gridPositionSchema.describe('Position in 12-column grid'),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe(
'Required for GRAPH and RECORD_TABLE widgets: object UUID to aggregate or display',
'For GRAPH and RECORD_TABLE widgets: object UUID to aggregate or display. Provide this or objectName.',
),
objectName: z
.string()
.optional()
.describe(
'For GRAPH and RECORD_TABLE widgets: object name, singular or plural. Resolved to a UUID — alternative to objectMetadataId.',
),
configuration: widgetConfigurationSchema,
});
export const createAddDashboardWidgetTool = (
deps: Pick<DashboardToolDependencies, 'pageLayoutWidgetService'>,
deps: Pick<
DashboardToolDependencies,
'pageLayoutWidgetService' | 'flatEntityMapsCacheService'
>,
context: DashboardToolContext,
) => ({
name: 'add_dashboard_widget' as const,
description: `Add a widget to an existing dashboard tab.
Use get_dashboard first to get pageLayoutTabId and existing widget positions.
Use get_object_metadata and get_field_metadata to get objectMetadataId and field IDs for GRAPH widgets.
You can reference the object and fields by NAME instead of UUID: pass objectName on the widget and the *FieldName variants in configuration (aggregateFieldName, primaryAxisGroupByFieldName, secondaryAxisGroupByFieldName, groupByFieldName). They are resolved server-side, so get_object_metadata / get_field_metadata are usually unnecessary. UUID variants still work and take precedence.
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.
Chart widgets (AGGREGATE_CHART, BAR_CHART, LINE_CHART, PIE_CHART) accept configuration.filter to restrict which records feed the chart, e.g. filter: { recordFilters: [{ fieldName: "createdAt", operand: "IS_RELATIVE", value: "PAST_7_DAY" }] }. Filter fields can be referenced by fieldName or fieldMetadataId and must belong to the widget object.
For RECORD_TABLE widgets: create a dedicated view first with upsert_complete_view (type TABLE, with its fields/filters/sorts in one call), then pass its viewId in configuration. Never reuse an existing record index view.
See create_complete_dashboard for full configuration examples.`,
inputSchema: addDashboardWidgetSchema,
@@ -53,11 +65,24 @@ See create_complete_dashboard for full configuration examples.`,
columnSpan: number;
};
objectMetadataId?: string;
configuration?: AllPageLayoutWidgetConfiguration;
objectName?: string;
configuration?: WidgetConfigurationInput;
}) => {
try {
const identifierMaps = await computeDashboardIdentifierMaps(
deps,
context,
);
const widgetWithMetadataIds = resolveWidgetFieldNamesToIds(
parameters,
identifierMaps,
);
const widget = await deps.pageLayoutWidgetService.create({
input: parameters as CreatePageLayoutWidgetInput,
input: {
...widgetWithMetadataIds,
pageLayoutTabId: parameters.pageLayoutTabId,
} as CreatePageLayoutWidgetInput,
workspaceId: context.workspaceId,
});
@@ -3,7 +3,6 @@ import { z } from 'zod';
import { type CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/create-page-layout-widget.input';
import { type WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
@@ -15,17 +14,25 @@ import {
type DashboardToolContext,
type DashboardToolDependencies,
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
import { type WidgetConfigurationInput } from 'src/modules/dashboard/tools/types/widget-configuration-input.type';
import { computeDashboardIdentifierMaps } from 'src/modules/dashboard/tools/utils/compute-dashboard-identifier-maps.util';
import { resolveWidgetFieldNamesToIds } from 'src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util';
const widgetSchema = z.object({
title: z.string().describe('Widget title displayed in the header'),
type: widgetTypeSchema.describe('Widget type'),
gridPosition: gridPositionSchema.describe('Position in 12-column grid'),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe(
'REQUIRED for GRAPH and RECORD_TABLE widgets: UUID of the object to aggregate or display',
'For GRAPH and RECORD_TABLE widgets: UUID of the object to aggregate or display. Provide this or objectName.',
),
objectName: z
.string()
.optional()
.describe(
'For GRAPH and RECORD_TABLE widgets: object name, singular or plural (e.g. "opportunity"). Resolved to a UUID — alternative to objectMetadataId.',
),
configuration: widgetConfigurationSchema,
});
@@ -51,7 +58,7 @@ export const createCreateCompleteDashboardTool = (
name: 'create_complete_dashboard' as const,
description: `Create a dashboard with layout, tab, and widgets.
IMPORTANT: Before creating GRAPH widgets, you MUST use get_object_metadata and get_field_metadata to get valid objectMetadataId and field IDs.
OBJECT & FIELD REFERENCES: You can reference the object and fields by NAME instead of UUID. Use objectName (e.g. "opportunity") on the widget and the *FieldName variants in configuration (aggregateFieldName, primaryAxisGroupByFieldName, secondaryAxisGroupByFieldName, groupByFieldName). They are resolved to UUIDs server-side, so you usually do NOT need get_object_metadata / get_field_metadata first. UUID variants (objectMetadataId, *FieldMetadataId) still work and take precedence when both are given.
GRID SYSTEM:
- 12 columns (0-11), rows start at 0
@@ -78,6 +85,14 @@ WIDGET TYPES:
- Additional required: configuration.groupByFieldMetadataId (note: different field name!)
- Example: { type: "GRAPH", objectMetadataId: "<opportunity-object-uuid>", configuration: { configurationType: "PIE_CHART", aggregateFieldMetadataId: "<id-field-uuid>", aggregateOperation: "COUNT", groupByFieldMetadataId: "<stage-field-uuid>" } }
CHART FILTERS (AGGREGATE_CHART, BAR_CHART, LINE_CHART, PIE_CHART):
- Add configuration.filter to restrict which records feed the chart. All filtered fields must belong to the widget object.
- Reference filter fields by name (fieldName) or UUID (fieldMetadataId).
- Shape: { filter: { recordFilters: [{ fieldName, operand, value, subFieldName? }] } }. Multiple rules are ANDed; use recordFilterGroups with logicalOperator AND/OR for advanced logic.
- Relative dates: operand "IS_RELATIVE" with value like "PAST_7_DAY", "THIS_1_MONTH", "NEXT_3_WEEK" (DIRECTION_AMOUNT_UNIT; DIRECTION=PAST|THIS|NEXT, UNIT=DAY|WEEK|MONTH|QUARTER|YEAR). Use IS_IN_PAST/IS_IN_FUTURE/IS_TODAY (no value) for open-ended ranges.
- SELECT/MULTI_SELECT/RELATION values are JSON array strings, e.g. '["WON"]'. CURRENCY value is the major unit with subFieldName "amountMicros".
- Example (won opportunities created in the last 30 days): { type: "GRAPH", objectName: "opportunity", configuration: { configurationType: "AGGREGATE_CHART", aggregateFieldName: "amount", aggregateOperation: "SUM", filter: { recordFilters: [{ fieldName: "stage", operand: "IS", value: "[\\"WON\\"]" }, { fieldName: "createdAt", operand: "IS_RELATIVE", value: "PAST_30_DAY" }] } } }
5. IFRAME: { type: "IFRAME", configuration: { configurationType: "IFRAME", url: "https://..." } }
6. STANDALONE_RICH_TEXT: { type: "STANDALONE_RICH_TEXT", configuration: { configurationType: "STANDALONE_RICH_TEXT", body: { ... } } }
@@ -105,12 +120,17 @@ AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY`
columnSpan: number;
};
objectMetadataId?: string;
configuration?: AllPageLayoutWidgetConfiguration;
objectName?: string;
configuration?: WidgetConfigurationInput;
}>;
}) => {
try {
const tabTitle = parameters.tabTitle ?? 'Main';
const widgets = parameters.widgets ?? [];
const identifierMaps =
widgets.length > 0
? await computeDashboardIdentifierMaps(deps, context)
: null;
const pageLayout = await deps.pageLayoutService.create({
createPageLayoutInput: {
name: parameters.title,
@@ -133,9 +153,13 @@ AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY`
for (const widget of widgets) {
try {
const widgetWithMetadataIds = identifierMaps
? resolveWidgetFieldNamesToIds(widget, identifierMaps)
: widget;
const createdWidget = await deps.pageLayoutWidgetService.create({
input: {
...widget,
...widgetWithMetadataIds,
pageLayoutTabId: pageLayoutTab.id,
} as CreatePageLayoutWidgetInput,
workspaceId: context.workspaceId,
@@ -2,6 +2,7 @@ import { isNumber } from '@sniptt/guards';
import {
AggregateOperations,
ObjectRecordGroupByDateGranularity,
ViewFilterOperand,
} from 'twenty-shared/types';
import { z } from 'zod';
@@ -85,6 +86,83 @@ const AGGREGATE_OPERATION_OPTIONS = Object.values(AggregateOperations) as [
...AggregateOperations[],
];
const FILTER_OPERAND_OPTIONS = Object.values(ViewFilterOperand) as [
ViewFilterOperand,
...ViewFilterOperand[],
];
const chartRecordFilterSchema = z.object({
fieldMetadataId: z
.uuid()
.optional()
.describe(
'Field UUID to filter on (must belong to the widget object). Provide this or fieldName.',
),
fieldName: z
.string()
.optional()
.describe(
'Field name to filter on (resolved to a UUID against the widget object). Alternative to fieldMetadataId.',
),
operand: z
.enum(FILTER_OPERAND_OPTIONS)
.describe(
'Filter operator. Valid operators per field type — TEXT/EMAILS/FULL_NAME/ARRAY/PHONES: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY. NUMBER/CURRENCY/RATING: GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS, IS_NOT, 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: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY. RELATION: IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY. BOOLEAN: IS.',
),
value: z
.string()
.optional()
.describe(
'Filter value as a string. TEXT: plain string. NUMBER/CURRENCY: numeric string (CURRENCY value is the major unit, e.g. "1000"). BOOLEAN: "true" or "false". SELECT/MULTI_SELECT/RELATION: JSON array string of option values or record UUIDs, e.g. \'["OPTION_1","OPTION_2"]\'. Relative dates (operand IS_RELATIVE): "DIRECTION_AMOUNT_UNIT" where DIRECTION is PAST|THIS|NEXT and UNIT is DAY|WEEK|MONTH|QUARTER|YEAR — e.g. "PAST_7_DAY", "THIS_1_MONTH", "NEXT_3_WEEK" (use THIS_1_<UNIT> for the current period). Absolute dates (IS/IS_BEFORE/IS_AFTER): ISO date string. Omit for IS_EMPTY/IS_NOT_EMPTY/IS_TODAY/IS_IN_PAST/IS_IN_FUTURE.',
),
subFieldName: z
.string()
.optional()
.describe(
'Required for composite fields — e.g. "amountMicros" or "currencyCode" for CURRENCY, "addressCity" for ADDRESS, "firstName"/"lastName" for FULL_NAME.',
),
recordFilterGroupId: z
.string()
.optional()
.describe(
'ID of the record filter group this rule belongs to (for AND/OR grouping). Must match an id in recordFilterGroups. Omit when recordFilterGroups is not used.',
),
});
const chartRecordFilterGroupSchema = z.object({
id: z
.string()
.describe(
'Unique id for this filter group, referenced by recordFilterGroupId on filter rules.',
),
logicalOperator: z
.enum(['AND', 'OR'])
.describe('How rules within this group are combined.'),
parentRecordFilterGroupId: z
.string()
.optional()
.describe('Parent group id, for nested grouping.'),
});
export const chartFilterSchema = z
.object({
recordFilters: z
.array(chartRecordFilterSchema)
.optional()
.describe(
'Filter rules applied to the records feeding this chart. Multiple rules with no recordFilterGroups are combined with AND.',
),
recordFilterGroups: z
.array(chartRecordFilterGroupSchema)
.optional()
.describe(
'Optional groups to combine filter rules with AND/OR logic. Omit for a simple list of ANDed rules.',
),
})
.describe(
'Filter restricting which records are included in this chart. All filtered fields must belong to the widget object.',
);
const displayDataLabelSchema = z.boolean().optional();
const displayLegendSchema = z.boolean().optional();
const showCenterMetricSchema = z
@@ -114,7 +192,16 @@ type RangeMinMaxFields = {
};
const ratioAggregateConfigSchema = z.object({
fieldMetadataId: z.uuid(),
fieldMetadataId: z
.uuid()
.optional()
.describe('Field UUID. Provide this or fieldName.'),
fieldName: z
.string()
.optional()
.describe(
'Field name (resolved to a UUID). Alternative to fieldMetadataId.',
),
optionValue: z.string(),
});
@@ -205,8 +292,15 @@ const aggregateChartConfigSchemaBase = z.object({
configurationType: z.literal(WidgetConfigurationType.AGGREGATE_CHART),
aggregateFieldMetadataId: z
.uuid()
.optional()
.describe(
'Field UUID to aggregate (must be from the widget objectMetadataId)',
'Field UUID to aggregate (must be from the widget object). Provide this or aggregateFieldName.',
),
aggregateFieldName: z
.string()
.optional()
.describe(
'Field name to aggregate (resolved to a UUID against the widget object). Alternative to aggregateFieldMetadataId.',
),
aggregateOperation: z
.enum(AGGREGATE_OPERATION_OPTIONS)
@@ -222,6 +316,7 @@ const aggregateChartConfigSchemaBase = z.object({
prefix: z.string().optional(),
suffix: z.string().optional(),
ratioAggregateConfig: ratioAggregateConfigSchema.optional(),
filter: chartFilterSchema.optional(),
});
const aggregateChartConfigSchema = aggregateChartConfigSchemaBase.extend({
@@ -234,11 +329,29 @@ const aggregateChartConfigSchemaWithoutDefaults =
// Graph configuration schema for BAR charts
const barChartConfigSchemaCore = z.object({
configurationType: z.literal(WidgetConfigurationType.BAR_CHART),
aggregateFieldMetadataId: z.uuid().describe('Field UUID to aggregate'),
aggregateFieldMetadataId: z
.uuid()
.optional()
.describe('Field UUID to aggregate. Provide this or aggregateFieldName.'),
aggregateFieldName: z
.string()
.optional()
.describe(
'Field name to aggregate (resolved to a UUID). Alternative to aggregateFieldMetadataId.',
),
aggregateOperation: z.enum(AGGREGATE_OPERATION_OPTIONS),
primaryAxisGroupByFieldMetadataId: z
.uuid()
.describe('Field UUID to group by on primary axis'),
.optional()
.describe(
'Field UUID to group by on primary axis. Provide this or primaryAxisGroupByFieldName.',
),
primaryAxisGroupByFieldName: z
.string()
.optional()
.describe(
'Field name to group by on primary axis (resolved to a UUID). Alternative to primaryAxisGroupByFieldMetadataId.',
),
primaryAxisGroupBySubFieldName: z
.string()
.optional()
@@ -246,6 +359,12 @@ const barChartConfigSchemaCore = z.object({
'REQUIRED for relation fields (e.g. "name", "address.addressCity") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.',
),
secondaryAxisGroupByFieldMetadataId: z.uuid().optional(),
secondaryAxisGroupByFieldName: z
.string()
.optional()
.describe(
'Field name to group by on secondary axis (resolved to a UUID). Alternative to secondaryAxisGroupByFieldMetadataId.',
),
secondaryAxisGroupBySubFieldName: z
.string()
.optional()
@@ -282,6 +401,7 @@ const barChartConfigSchemaCore = z.object({
layout: z
.enum(BAR_CHART_LAYOUT_OPTIONS)
.describe('Layout orientation for bar charts'),
filter: chartFilterSchema.optional(),
});
const barChartConfigSchemaWithoutDefaults = withRangeMinMaxRefinement(
@@ -300,9 +420,29 @@ const barChartConfigSchema = withRangeMinMaxRefinement(
// Graph configuration schema for LINE charts
const lineChartConfigSchemaCore = z.object({
configurationType: z.literal(WidgetConfigurationType.LINE_CHART),
aggregateFieldMetadataId: z.uuid(),
aggregateFieldMetadataId: z
.uuid()
.optional()
.describe('Field UUID to aggregate. Provide this or aggregateFieldName.'),
aggregateFieldName: z
.string()
.optional()
.describe(
'Field name to aggregate (resolved to a UUID). Alternative to aggregateFieldMetadataId.',
),
aggregateOperation: z.enum(AGGREGATE_OPERATION_OPTIONS),
primaryAxisGroupByFieldMetadataId: z.uuid(),
primaryAxisGroupByFieldMetadataId: z
.uuid()
.optional()
.describe(
'Field UUID to group by on primary axis. Provide this or primaryAxisGroupByFieldName.',
),
primaryAxisGroupByFieldName: z
.string()
.optional()
.describe(
'Field name to group by on primary axis (resolved to a UUID). Alternative to primaryAxisGroupByFieldMetadataId.',
),
primaryAxisGroupBySubFieldName: z
.string()
.optional()
@@ -310,6 +450,12 @@ const lineChartConfigSchemaCore = z.object({
'REQUIRED for relation fields (e.g. "name", "address.addressCity") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.',
),
secondaryAxisGroupByFieldMetadataId: z.uuid().optional(),
secondaryAxisGroupByFieldName: z
.string()
.optional()
.describe(
'Field name to group by on secondary axis (resolved to a UUID). Alternative to secondaryAxisGroupByFieldMetadataId.',
),
secondaryAxisGroupBySubFieldName: z
.string()
.optional()
@@ -340,6 +486,7 @@ const lineChartConfigSchemaCore = z.object({
isCumulative: z.boolean().optional().describe('Show running totals'),
rangeMin: z.number().optional().describe('Y axis minimum value'),
rangeMax: z.number().optional().describe('Y axis maximum value'),
filter: chartFilterSchema.optional(),
});
const lineChartConfigSchemaWithoutDefaults = withRangeMinMaxRefinement(
@@ -358,9 +505,27 @@ const lineChartConfigSchema = withRangeMinMaxRefinement(
// Graph configuration schema for PIE charts
const pieChartConfigSchemaCore = z.object({
configurationType: z.literal(WidgetConfigurationType.PIE_CHART),
aggregateFieldMetadataId: z.uuid(),
aggregateFieldMetadataId: z
.uuid()
.optional()
.describe('Field UUID to aggregate. Provide this or aggregateFieldName.'),
aggregateFieldName: z
.string()
.optional()
.describe(
'Field name to aggregate (resolved to a UUID). Alternative to aggregateFieldMetadataId.',
),
aggregateOperation: z.enum(AGGREGATE_OPERATION_OPTIONS),
groupByFieldMetadataId: z.uuid().describe('Field UUID to slice by'),
groupByFieldMetadataId: z
.uuid()
.optional()
.describe('Field UUID to slice by. Provide this or groupByFieldName.'),
groupByFieldName: z
.string()
.optional()
.describe(
'Field name to slice by (resolved to a UUID). Alternative to groupByFieldMetadataId.',
),
groupBySubFieldName: z
.string()
.optional()
@@ -378,6 +543,7 @@ const pieChartConfigSchemaCore = z.object({
displayLegend: displayLegendSchema,
showCenterMetric: showCenterMetricSchema,
hideEmptyCategory: hideEmptyCategorySchema,
filter: chartFilterSchema.optional(),
});
const pieChartConfigSchemaWithoutDefaults = withManualSortRefinement(
@@ -0,0 +1,5 @@
import { type z } from 'zod';
import { type chartFilterSchema } from 'src/modules/dashboard/tools/schemas/widget.schema';
export type ChartFilterInput = z.infer<typeof chartFilterSchema>;
@@ -0,0 +1,7 @@
import { FieldMetadataType } from 'twenty-shared/types';
export type DashboardIdentifierMaps = {
objectIdByName: Record<string, string>;
fieldIdByObjectIdAndName: Map<string, string>;
fieldById: Map<string, { type: FieldMetadataType }>;
};
@@ -0,0 +1,7 @@
import { type z } from 'zod';
import { type widgetConfigurationSchema } from 'src/modules/dashboard/tools/schemas/widget.schema';
export type WidgetConfigurationInput = NonNullable<
z.infer<typeof widgetConfigurationSchema>
>;
@@ -0,0 +1,14 @@
import { type z } from 'zod';
import { type WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { type gridPositionSchema } from 'src/modules/dashboard/tools/schemas/widget.schema';
import { type WidgetConfigurationInput } from 'src/modules/dashboard/tools/types/widget-configuration-input.type';
export type WidgetIdentifiersInput = {
title: string;
type: WidgetType;
gridPosition: z.infer<typeof gridPositionSchema>;
objectMetadataId?: string;
objectName?: string;
configuration?: WidgetConfigurationInput;
};
@@ -0,0 +1,13 @@
import { type z } from 'zod';
import { type WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { type gridPositionSchema } from 'src/modules/dashboard/tools/schemas/widget.schema';
export type WidgetWithMetadataIds = {
title: string;
type: WidgetType;
gridPosition: z.infer<typeof gridPositionSchema>;
objectMetadataId?: string;
configuration?: AllPageLayoutWidgetConfiguration;
};
@@ -1,3 +1,4 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined, isEmptyObject } from 'twenty-shared/utils';
import { z } from 'zod';
@@ -12,6 +13,12 @@ import {
type DashboardToolContext,
type DashboardToolDependencies,
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
import { type WidgetConfigurationInput } from 'src/modules/dashboard/tools/types/widget-configuration-input.type';
import { computeDashboardIdentifierMaps } from 'src/modules/dashboard/tools/utils/compute-dashboard-identifier-maps.util';
import {
getObjectMetadataId,
resolveConfigurationFieldNamesToIds,
} from 'src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util';
const updateDashboardWidgetSchema = z.object({
widgetId: z.string().uuid().describe('The UUID of the widget to update'),
@@ -21,15 +28,23 @@ const updateDashboardWidgetSchema = z.object({
.optional()
.describe('New position and size in the grid layout'),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe('New object metadata ID'),
.describe('New object metadata ID. Provide this or objectName.'),
objectName: z
.string()
.optional()
.describe(
'New object name, singular or plural. Resolved to a UUID — alternative to objectMetadataId.',
),
configuration: widgetConfigurationSchemaWithoutDefaults.optional(),
});
export const createUpdateDashboardWidgetTool = (
deps: Pick<DashboardToolDependencies, 'pageLayoutWidgetService'>,
deps: Pick<
DashboardToolDependencies,
'pageLayoutWidgetService' | 'flatEntityMapsCacheService'
>,
context: DashboardToolContext,
) => ({
name: 'update_dashboard_widget' as const,
@@ -37,6 +52,8 @@ export const createUpdateDashboardWidgetTool = (
Use get_dashboard first to find the widgetId.
You can reference the object and fields by NAME instead of UUID: pass objectName and the *FieldName variants in configuration (aggregateFieldName, primaryAxisGroupByFieldName, secondaryAxisGroupByFieldName, groupByFieldName) and fieldName inside filter recordFilters. They are resolved server-side against the widget object, falling back to the widget's existing object when you don't change it. UUID variants still work and take precedence.
Only provide fields you want to change - others remain unchanged.`,
inputSchema: updateDashboardWidgetSchema,
execute: async (parameters: {
@@ -50,12 +67,56 @@ Only provide fields you want to change - others remain unchanged.`,
columnSpan: number;
};
objectMetadataId?: string;
configuration?: AllPageLayoutWidgetConfiguration;
objectName?: string;
configuration?: WidgetConfigurationInput;
}) => {
try {
const { widgetId, ...updates } = parameters;
const { widgetId, objectName, configuration, ...rest } = parameters;
const hasConfigurationUpdate =
isDefined(configuration) && !isEmptyObject(configuration);
const shouldResolveIdentifiers =
hasConfigurationUpdate || isNonEmptyString(objectName);
let resolvedObjectMetadataId = rest.objectMetadataId;
let resolvedConfiguration: AllPageLayoutWidgetConfiguration | undefined;
if (shouldResolveIdentifiers) {
const identifierMaps = await computeDashboardIdentifierMaps(
deps,
context,
);
resolvedObjectMetadataId = getObjectMetadataId({
objectMetadataId: rest.objectMetadataId,
objectName,
maps: identifierMaps,
});
if (isDefined(configuration) && !isEmptyObject(configuration)) {
const objectMetadataIdForFields =
resolvedObjectMetadataId ??
(
await deps.pageLayoutWidgetService.findByIdOrThrow({
id: widgetId,
workspaceId: context.workspaceId,
})
).objectMetadataId;
resolvedConfiguration = resolveConfigurationFieldNamesToIds(
configuration,
objectMetadataIdForFields,
identifierMaps,
);
}
}
const updateData = Object.fromEntries(
Object.entries(updates).filter(([key, value]) => {
Object.entries({
...rest,
objectMetadataId: resolvedObjectMetadataId,
configuration: resolvedConfiguration,
}).filter(([key, value]) => {
if (!isDefined(value)) {
return false;
}
@@ -0,0 +1,215 @@
import {
AggregateOperations,
FieldMetadataType,
ViewFilterOperand,
} from 'twenty-shared/types';
import { buildFieldByObjectIdAndNameKey } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { type DashboardIdentifierMaps } from 'src/modules/dashboard/tools/types/dashboard-identifier-maps.type';
import { type WidgetIdentifiersInput } from 'src/modules/dashboard/tools/types/widget-identifiers-input.type';
import { resolveWidgetFieldNamesToIds } from 'src/modules/dashboard/tools/utils/resolve-widget-field-names-to-metadata-ids.util';
const OBJECT_ID = '11111111-1111-4111-8111-111111111111';
const AMOUNT_FIELD_ID = '22222222-2222-4222-8222-222222222222';
const STAGE_FIELD_ID = '33333333-3333-4333-8333-333333333333';
const CREATED_AT_FIELD_ID = '44444444-4444-4444-8444-444444444444';
const buildMaps = (): DashboardIdentifierMaps => ({
objectIdByName: { opportunity: OBJECT_ID, opportunities: OBJECT_ID },
fieldIdByObjectIdAndName: new Map([
[buildFieldByObjectIdAndNameKey(OBJECT_ID, 'amount'), AMOUNT_FIELD_ID],
[buildFieldByObjectIdAndNameKey(OBJECT_ID, 'stage'), STAGE_FIELD_ID],
[
buildFieldByObjectIdAndNameKey(OBJECT_ID, 'createdAt'),
CREATED_AT_FIELD_ID,
],
]),
fieldById: new Map([
[AMOUNT_FIELD_ID, { type: FieldMetadataType.CURRENCY }],
[STAGE_FIELD_ID, { type: FieldMetadataType.SELECT }],
[CREATED_AT_FIELD_ID, { type: FieldMetadataType.DATE_TIME }],
]),
});
const getResolvedFilter = (configuration: unknown) =>
(
configuration as {
filter: {
recordFilters: Array<Record<string, unknown>>;
recordFilterGroups: Array<Record<string, unknown>>;
};
}
).filter;
const buildAggregateWidget = (
filter: NonNullable<
Extract<
WidgetIdentifiersInput['configuration'],
{ configurationType: WidgetConfigurationType.AGGREGATE_CHART }
>
>['filter'],
): WidgetIdentifiersInput => ({
title: 'KPI',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 4 },
objectName: 'opportunity',
configuration: {
configurationType: WidgetConfigurationType.AGGREGATE_CHART,
aggregateFieldName: 'amount',
aggregateOperation: AggregateOperations.SUM,
displayDataLabel: true,
filter,
},
});
describe('resolveWidgetFieldNamesToIds - chart filters', () => {
it('resolves filter fieldName to fieldMetadataId', () => {
const widget = buildAggregateWidget({
recordFilters: [
{
fieldName: 'stage',
operand: ViewFilterOperand.IS,
value: '["WON"]',
},
],
});
const result = resolveWidgetFieldNamesToIds(widget, buildMaps());
expect(result.configuration).toMatchObject({
aggregateFieldMetadataId: AMOUNT_FIELD_ID,
filter: {
recordFilters: [
{
fieldMetadataId: STAGE_FIELD_ID,
operand: ViewFilterOperand.IS,
value: '["WON"]',
},
],
},
});
expect(
(result.configuration as { filter: { recordFilters: unknown[] } }).filter
.recordFilters[0],
).not.toHaveProperty('fieldName');
});
it('enriches the filter with a root group, type and displayValue for the UI', () => {
const widget = buildAggregateWidget({
recordFilters: [
{
fieldName: 'stage',
operand: ViewFilterOperand.IS_NOT,
value: '["NEW"]',
},
],
});
const result = resolveWidgetFieldNamesToIds(widget, buildMaps());
const filter = getResolvedFilter(result.configuration);
expect(filter.recordFilterGroups).toHaveLength(1);
const rootGroup = filter.recordFilterGroups[0];
expect(rootGroup).toMatchObject({ logicalOperator: 'AND' });
expect(rootGroup.parentRecordFilterGroupId).toBeUndefined();
expect(filter.recordFilters[0]).toMatchObject({
fieldMetadataId: STAGE_FIELD_ID,
operand: ViewFilterOperand.IS_NOT,
value: '["NEW"]',
displayValue: '["NEW"]',
type: 'SELECT',
recordFilterGroupId: rootGroup.id,
positionInRecordFilterGroup: 0,
});
expect(filter.recordFilters[0].id).toEqual(expect.any(String));
});
it('preserves an explicit filter fieldMetadataId', () => {
const widget = buildAggregateWidget({
recordFilters: [
{
fieldMetadataId: STAGE_FIELD_ID,
operand: ViewFilterOperand.IS_NOT,
value: '["LOST"]',
},
],
});
const result = resolveWidgetFieldNamesToIds(widget, buildMaps());
expect(result.configuration).toMatchObject({
filter: {
recordFilters: [{ fieldMetadataId: STAGE_FIELD_ID }],
},
});
});
it('passes relative date filter values through unchanged', () => {
const widget = buildAggregateWidget({
recordFilters: [
{
fieldName: 'createdAt',
operand: ViewFilterOperand.IS_RELATIVE,
value: 'PAST_7_DAY',
},
],
});
const result = resolveWidgetFieldNamesToIds(widget, buildMaps());
expect(result.configuration).toMatchObject({
filter: {
recordFilters: [
{
fieldMetadataId: CREATED_AT_FIELD_ID,
operand: ViewFilterOperand.IS_RELATIVE,
value: 'PAST_7_DAY',
},
],
},
});
});
it('keeps recordFilterGroups for AND/OR logic', () => {
const widget = buildAggregateWidget({
recordFilters: [
{
fieldName: 'stage',
operand: ViewFilterOperand.IS,
value: '["WON"]',
recordFilterGroupId: 'group-1',
},
],
recordFilterGroups: [{ id: 'group-1', logicalOperator: 'OR' }],
});
const result = resolveWidgetFieldNamesToIds(widget, buildMaps());
expect(result.configuration).toMatchObject({
filter: {
recordFilterGroups: [{ id: 'group-1', logicalOperator: 'OR' }],
},
});
});
it('throws a helpful error when a filter field name is unknown', () => {
const widget = buildAggregateWidget({
recordFilters: [
{
fieldName: 'nonExistentField',
operand: ViewFilterOperand.IS,
value: '["X"]',
},
],
});
expect(() => resolveWidgetFieldNamesToIds(widget, buildMaps())).toThrow(
/Field "nonExistentField" not found/,
);
});
});
@@ -0,0 +1,34 @@
import { buildFieldIdByNameMaps } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-id-by-name-maps.util';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { type DashboardIdentifierMaps } from 'src/modules/dashboard/tools/types/dashboard-identifier-maps.type';
import {
type DashboardToolContext,
type DashboardToolDependencies,
} from 'src/modules/dashboard/tools/types/dashboard-tool-dependencies.type';
export const computeDashboardIdentifierMaps = async (
deps: Pick<DashboardToolDependencies, 'flatEntityMapsCacheService'>,
context: DashboardToolContext,
): Promise<DashboardIdentifierMaps> => {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await deps.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: context.workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const { idByNameSingular, idByNamePlural } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
const { fieldIdByObjectIdAndName, fieldById } = buildFieldIdByNameMaps(
flatFieldMetadataMaps,
);
return {
objectIdByName: { ...idByNamePlural, ...idByNameSingular },
fieldIdByObjectIdAndName,
fieldById,
};
};
@@ -0,0 +1,364 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
type ChartFilter,
type ChartRecordFilter,
type ChartRecordFilterGroup,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { buildFieldByObjectIdAndNameKey } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-by-object-id-and-name-key.util';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { type ChartFilterInput } from 'src/modules/dashboard/tools/types/chart-filter-input.type';
import { type DashboardIdentifierMaps } from 'src/modules/dashboard/tools/types/dashboard-identifier-maps.type';
import { type WidgetConfigurationInput } from 'src/modules/dashboard/tools/types/widget-configuration-input.type';
import { type WidgetIdentifiersInput } from 'src/modules/dashboard/tools/types/widget-identifiers-input.type';
import { type WidgetWithMetadataIds } from 'src/modules/dashboard/tools/types/widget-with-metadata-ids.type';
const getFieldMetadataIdOrThrow = (
{
fieldMetadataId,
fieldName,
objectMetadataId,
maps,
}: {
fieldMetadataId: string | undefined;
fieldName: string | undefined;
objectMetadataId: string | undefined;
maps: DashboardIdentifierMaps;
},
referenceLabel: string,
): string => {
if (isDefined(fieldMetadataId)) {
return fieldMetadataId;
}
if (!isNonEmptyString(fieldName)) {
throw new Error(
`Missing required ${referenceLabel}: provide either its UUID or its field name.`,
);
}
if (!isDefined(objectMetadataId)) {
throw new Error(
`Cannot look up field "${fieldName}": the widget has no object. Provide objectName or objectMetadataId on the widget.`,
);
}
const fieldMetadataIdForName = maps.fieldIdByObjectIdAndName.get(
buildFieldByObjectIdAndNameKey(objectMetadataId, fieldName),
);
if (!isDefined(fieldMetadataIdForName)) {
throw new Error(
`Field "${fieldName}" not found on this object. Use get_object_metadata with includeFields (or get_field_metadata) to list available field names.`,
);
}
return fieldMetadataIdForName;
};
export const getObjectMetadataId = ({
objectMetadataId,
objectName,
maps,
}: {
objectMetadataId: string | undefined;
objectName: string | undefined;
maps: DashboardIdentifierMaps;
}): string | undefined => {
if (isDefined(objectMetadataId)) {
return objectMetadataId;
}
if (!isNonEmptyString(objectName)) {
return undefined;
}
const objectMetadataIdForName = maps.objectIdByName[objectName];
if (!isDefined(objectMetadataIdForName)) {
throw new Error(
`Object "${objectName}" not found. Use get_object_metadata to list available objects.`,
);
}
return objectMetadataIdForName;
};
type ResolvedChartRecordFilter = ChartRecordFilter & {
id: string;
displayValue: string;
positionInRecordFilterGroup: number;
};
const resolveChartFilterFieldNamesToIds = (
filter: ChartFilterInput | undefined,
objectMetadataId: string | undefined,
maps: DashboardIdentifierMaps,
): ChartFilter | undefined => {
if (!isDefined(filter)) {
return undefined;
}
const inputRecordFilters = filter.recordFilters ?? [];
if (inputRecordFilters.length === 0) {
return undefined;
}
const inputRecordFilterGroups = filter.recordFilterGroups ?? [];
const existingRootGroup = inputRecordFilterGroups.find(
(group) => !isDefined(group.parentRecordFilterGroupId),
);
const rootGroup: ChartRecordFilterGroup = existingRootGroup ?? {
id: uuidv4(),
logicalOperator: 'AND',
};
const recordFilterGroups: ChartRecordFilterGroup[] = isDefined(
existingRootGroup,
)
? inputRecordFilterGroups
: [rootGroup, ...inputRecordFilterGroups];
const validGroupIds = new Set(recordFilterGroups.map((group) => group.id));
const positionByGroupId = new Map<string, number>();
const recordFilters: ResolvedChartRecordFilter[] = inputRecordFilters.map(
(recordFilter) => {
const { fieldName, fieldMetadataId, ...rest } = recordFilter;
const resolvedFieldMetadataId = getFieldMetadataIdOrThrow(
{
fieldMetadataId,
fieldName,
objectMetadataId,
maps,
},
'filter field',
);
const recordFilterGroupId = rest.recordFilterGroupId ?? rootGroup.id;
if (!validGroupIds.has(recordFilterGroupId)) {
throw new Error(
`Invalid recordFilterGroupId "${recordFilterGroupId}": no matching filter group exists. Provide a valid group id or omit to use the root group.`,
);
}
const positionInRecordFilterGroup =
positionByGroupId.get(recordFilterGroupId) ?? 0;
positionByGroupId.set(
recordFilterGroupId,
positionInRecordFilterGroup + 1,
);
const field = maps.fieldById.get(resolvedFieldMetadataId);
const value = rest.value ?? '';
return {
id: uuidv4(),
fieldMetadataId: resolvedFieldMetadataId,
operand: rest.operand,
value,
displayValue: value,
type: field?.type ?? '',
recordFilterGroupId,
positionInRecordFilterGroup,
...(isDefined(rest.subFieldName)
? { subFieldName: rest.subFieldName }
: {}),
};
},
);
return {
recordFilters,
recordFilterGroups,
};
};
export const resolveConfigurationFieldNamesToIds = (
configuration: WidgetConfigurationInput,
objectMetadataId: string | undefined,
maps: DashboardIdentifierMaps,
): AllPageLayoutWidgetConfiguration => {
switch (configuration.configurationType) {
case WidgetConfigurationType.AGGREGATE_CHART: {
const {
aggregateFieldName,
aggregateFieldMetadataId,
ratioAggregateConfig,
filter,
...rest
} = configuration;
return {
...rest,
aggregateFieldMetadataId: getFieldMetadataIdOrThrow(
{
fieldMetadataId: aggregateFieldMetadataId,
fieldName: aggregateFieldName,
objectMetadataId,
maps,
},
'aggregate field',
),
filter: resolveChartFilterFieldNamesToIds(
filter,
objectMetadataId,
maps,
),
ratioAggregateConfig: isDefined(ratioAggregateConfig)
? {
optionValue: ratioAggregateConfig.optionValue,
fieldMetadataId: getFieldMetadataIdOrThrow(
{
fieldMetadataId: ratioAggregateConfig.fieldMetadataId,
fieldName: ratioAggregateConfig.fieldName,
objectMetadataId,
maps,
},
'ratio aggregate field',
),
}
: undefined,
};
}
case WidgetConfigurationType.BAR_CHART:
case WidgetConfigurationType.LINE_CHART: {
const {
aggregateFieldName,
aggregateFieldMetadataId,
primaryAxisGroupByFieldName,
primaryAxisGroupByFieldMetadataId,
secondaryAxisGroupByFieldName,
secondaryAxisGroupByFieldMetadataId,
filter,
...rest
} = configuration;
return {
...rest,
aggregateFieldMetadataId: getFieldMetadataIdOrThrow(
{
fieldMetadataId: aggregateFieldMetadataId,
fieldName: aggregateFieldName,
objectMetadataId,
maps,
},
'aggregate field',
),
filter: resolveChartFilterFieldNamesToIds(
filter,
objectMetadataId,
maps,
),
primaryAxisGroupByFieldMetadataId: getFieldMetadataIdOrThrow(
{
fieldMetadataId: primaryAxisGroupByFieldMetadataId,
fieldName: primaryAxisGroupByFieldName,
objectMetadataId,
maps,
},
'primary axis group-by field',
),
secondaryAxisGroupByFieldMetadataId:
isDefined(secondaryAxisGroupByFieldMetadataId) ||
isNonEmptyString(secondaryAxisGroupByFieldName)
? getFieldMetadataIdOrThrow(
{
fieldMetadataId: secondaryAxisGroupByFieldMetadataId,
fieldName: secondaryAxisGroupByFieldName,
objectMetadataId,
maps,
},
'secondary axis group-by field',
)
: undefined,
};
}
case WidgetConfigurationType.PIE_CHART: {
const {
aggregateFieldName,
aggregateFieldMetadataId,
groupByFieldName,
groupByFieldMetadataId,
filter,
...rest
} = configuration;
return {
...rest,
aggregateFieldMetadataId: getFieldMetadataIdOrThrow(
{
fieldMetadataId: aggregateFieldMetadataId,
fieldName: aggregateFieldName,
objectMetadataId,
maps,
},
'aggregate field',
),
filter: resolveChartFilterFieldNamesToIds(
filter,
objectMetadataId,
maps,
),
groupByFieldMetadataId: getFieldMetadataIdOrThrow(
{
fieldMetadataId: groupByFieldMetadataId,
fieldName: groupByFieldName,
objectMetadataId,
maps,
},
'group-by field',
),
};
}
case WidgetConfigurationType.STANDALONE_RICH_TEXT:
return {
...configuration,
body: {
...configuration.body,
markdown: configuration.body.markdown ?? null,
},
};
case WidgetConfigurationType.IFRAME:
case WidgetConfigurationType.RECORD_TABLE:
return configuration;
}
};
export const resolveWidgetFieldNamesToIds = (
widget: WidgetIdentifiersInput,
maps: DashboardIdentifierMaps,
): WidgetWithMetadataIds => {
const objectMetadataId = getObjectMetadataId({
objectMetadataId: widget.objectMetadataId,
objectName: widget.objectName,
maps,
});
return {
title: widget.title,
type: widget.type,
gridPosition: widget.gridPosition,
objectMetadataId,
configuration: isDefined(widget.configuration)
? resolveConfigurationFieldNamesToIds(
widget.configuration,
objectMetadataId,
maps,
)
: undefined,
};
};
@@ -64,19 +64,6 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT shou
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should throw error if view filter value is not a stringified JSON array 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5",
},
]
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should update related multi selected options view filter 1`] = `
{
"operand": "CONTAINS",
@@ -174,19 +161,6 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should han
}
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should throw error if view filter value is not a stringified JSON array 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
"userFriendlyMessage": "An error occurred.",
},
"message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5",
},
]
`;
exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should update related multi selected options view filter 1`] = `
{
"operand": "IS",
@@ -309,8 +309,6 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => {
},
);
// Note these test exists only because we do not validate the view filter value on creation/update
// Should be removed after https://github.com/twentyhq/core-team-issues/issues/1009 completion
const failingTestCases: EachTestingContext<{
createViewFilterValue: unknown;
}>[] = [
@@ -334,45 +332,24 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => {
type: fieldType,
});
const viewFilterId = '20202020-e3b5-4fa7-85aa-9b1950fc7bf5';
await createOneViewFilter({
const { errors } = await createOneViewFilter({
input: {
id: viewFilterId,
id: '20202020-e3b5-4fa7-85aa-9b1950fc7bf5',
viewId: createdView.id,
fieldMetadataId: createOneField.id,
operand: operandForFieldType,
value: createViewFilterValue as unknown as ViewFilterValue,
},
expectToFail: false,
expectToFail: true,
gqlFields: `
id
`,
});
const optionsWithIds = createOneField.options;
if (!isDefined(optionsWithIds)) {
throw new Error('optionsWithIds is not defined');
}
const updatePayload = {
options: optionsWithIds.map((option) => fakeOptionUpdate(option)),
};
const { errors, data } = await updateOneFieldMetadata({
expectToFail: true,
input: {
idToUpdate: createOneField.id,
updatePayload,
},
gqlFields: `
id
options
`,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot();
expect(errors![0].extensions.code).toBe(
'METADATA_VALIDATION_FAILED',
);
},
);
});