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