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:
+177
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+30
-6
@@ -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>;
|
||||
+7
@@ -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 }>;
|
||||
};
|
||||
+7
@@ -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>
|
||||
>;
|
||||
+14
@@ -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;
|
||||
};
|
||||
+13
@@ -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;
|
||||
}
|
||||
|
||||
+215
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
+34
@@ -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,
|
||||
};
|
||||
};
|
||||
+364
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user