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
@@ -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,
};
};