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:
+11
-2
@@ -21,6 +21,9 @@ export const useRecordFilterField = (recordFilterId: string) => {
|
||||
recordFilter?.fieldMetadataId ?? '',
|
||||
);
|
||||
|
||||
const { fieldMetadataItem: relationTargetFieldMetadataItem } =
|
||||
useFieldMetadataItemById(recordFilter?.relationTargetFieldMetadataId ?? '');
|
||||
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const icon = isDefined(fieldMetadataItem?.icon)
|
||||
@@ -38,9 +41,15 @@ export const useRecordFilterField = (recordFilterId: string) => {
|
||||
)
|
||||
: '';
|
||||
|
||||
const fieldLabel = fieldMetadataItem?.label ?? '';
|
||||
|
||||
const baseLabel = isDefined(relationTargetFieldMetadataItem)
|
||||
? `${fieldLabel} → ${relationTargetFieldMetadataItem.label}`
|
||||
: fieldLabel;
|
||||
|
||||
const label = isNonEmptyString(subFieldLabel)
|
||||
? `${recordFilter?.label} / ${subFieldLabel}`
|
||||
: (recordFilter?.label ?? '');
|
||||
? `${baseLabel} / ${subFieldLabel}`
|
||||
: baseLabel;
|
||||
|
||||
return {
|
||||
label,
|
||||
|
||||
+5
-4
@@ -38,10 +38,11 @@ export const buildMcpServerInstructions = (
|
||||
` For complex tasks (workflows, metadata), load the matching skill BEFORE calling tools.`,
|
||||
` ⚠️ Never call workflow or metadata tools without loading their skill first.`,
|
||||
``,
|
||||
`Dashboards (coming soon):`,
|
||||
` Building or editing dashboards through the AI is not available yet — it is a coming soon feature.`,
|
||||
` If asked to create/build/modify a dashboard, do not attempt it: say AI-assisted dashboards are coming soon,`,
|
||||
` and offer alternatives (create views, run analytics with group_by_{objects}, or build workflows).`,
|
||||
`Dashboards:`,
|
||||
` To create/build/modify a dashboard, load the dashboard-building skill first, then resolve metadata and build.`,
|
||||
` Once metadata is resolved, emit create_complete_dashboard in the same turn — never stop on a "now let me…" preamble.`,
|
||||
` If a referenced field is missing (e.g. lead source, won/lost stage), pick a sensible default and state the assumption instead of stalling.`,
|
||||
` Informational dashboard questions ("what is a dashboard?") are NOT build requests — answer directly, do not load skill.`,
|
||||
``,
|
||||
`Route by intent:`,
|
||||
` Named entity ("Acme company") → find_many_{objects} to resolve id first, then operate on id`,
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const METADATA_TOOL_EXCLUDED_FIELD_NAMES = new Set([
|
||||
'searchVector',
|
||||
'position',
|
||||
'updatedBy',
|
||||
]);
|
||||
+3
-2
@@ -58,11 +58,12 @@ export class ViewToolProvider implements ToolProvider {
|
||||
|
||||
private async buildToolSet(context: ToolProviderContext): Promise<ToolSet> {
|
||||
const workspaceMemberId = context.actorContext?.workspaceMemberId;
|
||||
const userWorkspaceId = context.userWorkspaceId;
|
||||
|
||||
const readTools = {
|
||||
...this.viewToolsFactory.generateReadTools(
|
||||
context.workspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
userWorkspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
),
|
||||
...this.viewFieldToolsFactory.generateReadTools(context.workspaceId),
|
||||
@@ -84,7 +85,7 @@ export class ViewToolProvider implements ToolProvider {
|
||||
const writeTools = {
|
||||
...this.viewToolsFactory.generateWriteTools(
|
||||
context.workspaceId,
|
||||
workspaceMemberId ?? undefined,
|
||||
userWorkspaceId,
|
||||
),
|
||||
...this.viewFieldToolsFactory.generateWriteTools(context.workspaceId),
|
||||
...this.viewFilterToolsFactory.generateWriteTools(context.workspaceId),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module';
|
||||
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
|
||||
import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider';
|
||||
import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/providers/dashboard-tool.provider';
|
||||
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
|
||||
import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider';
|
||||
import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider';
|
||||
@@ -68,6 +69,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
|
||||
ToolIndexResolver,
|
||||
ToolExecutorService,
|
||||
ActionToolProvider,
|
||||
DashboardToolProvider,
|
||||
DatabaseToolProvider,
|
||||
MetadataToolProvider,
|
||||
NavigationMenuItemToolProvider,
|
||||
@@ -90,6 +92,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
|
||||
viewProvider: ViewToolProvider,
|
||||
webhookProvider: WebhookToolProvider,
|
||||
workflowProvider: WorkflowToolProvider,
|
||||
dashboardProvider: DashboardToolProvider,
|
||||
) => [
|
||||
actionProvider,
|
||||
databaseProvider,
|
||||
@@ -99,6 +102,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
|
||||
viewProvider,
|
||||
webhookProvider,
|
||||
workflowProvider,
|
||||
dashboardProvider,
|
||||
],
|
||||
inject: [
|
||||
ActionToolProvider,
|
||||
@@ -109,6 +113,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
|
||||
ViewToolProvider,
|
||||
WebhookToolProvider,
|
||||
WorkflowToolProvider,
|
||||
DashboardToolProvider,
|
||||
],
|
||||
},
|
||||
ToolRegistryService,
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const createLoadSkillTool = (
|
||||
label: skill.label,
|
||||
content: skill.content,
|
||||
})),
|
||||
message: `Loaded ${skills.map((skill) => skill.label).join(', ')}`,
|
||||
message: `Loaded ${skills.map((skill) => skill.label).join(', ')}.`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
+4
-2
@@ -20,9 +20,11 @@ Examples:
|
||||
|
||||
For simple CRUD operations (find/create/update/delete a record), you do NOT need a skill — but you still MUST call \`learn_tools\` first to learn the tool schema, then \`execute_tool\` to run it.
|
||||
|
||||
## Dashboards (coming soon)
|
||||
## Dashboards
|
||||
|
||||
Building or editing dashboards through the AI is not available yet — it is a coming soon feature. If the user asks you to create, build, or modify a dashboard, do NOT attempt it: let them know that AI-assisted dashboards are coming soon, and offer the alternatives you can help with today (e.g. creating views, running analytics with \`group_by_*\`, or building workflows).
|
||||
When the user asks to create, build, or modify a dashboard, load the \`dashboard-building\` skill and follow the Plan → Skill → Learn → Execute flow.
|
||||
|
||||
Intent gate: purely informational dashboard questions (e.g. "what is a dashboard in Twenty?", "how do I export a dashboard?", "can I share a dashboard with a client?") are NOT build requests. Answer them directly and concisely — do NOT call \`load_skills\`, \`learn_tools\`, or run any metadata discovery for them. Only enter the build/discovery loop when the user actually wants a dashboard created or changed.
|
||||
|
||||
## Skills vs Tools
|
||||
|
||||
|
||||
+1
@@ -47,6 +47,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
|
||||
TokenModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
FeatureFlagModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([
|
||||
|
||||
+56
-14
@@ -4,12 +4,14 @@ import { type ToolSet } from 'ai';
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant';
|
||||
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
|
||||
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
|
||||
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { getObjectMetadataIdByName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
|
||||
const EXCLUDED_FIELD_NAMES = new Set(['searchVector', 'position', 'updatedBy']);
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const FIELD_STRIP_WHEN_NULLISH = [
|
||||
'options',
|
||||
@@ -26,16 +28,14 @@ const FIELD_STRIP_WHEN_FALSE = ['isLabelSyncedWithName'];
|
||||
const FIELD_STRIP_WHEN_TRUE = ['isUIEditable'];
|
||||
|
||||
const GetFieldMetadataInputSchema = z.object({
|
||||
id: z
|
||||
id: z.uuid().optional().describe('Field ID. Returns one field if set.'),
|
||||
objectMetadataId: z.uuid().optional().describe('Filter by object ID.'),
|
||||
objectName: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Field ID. Returns one field if set.'),
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Filter by object ID.'),
|
||||
.describe(
|
||||
'Filter by object name, singular or plural (e.g. "opportunity" or "opportunities"). Convenient alternative to objectMetadataId so you do not need to resolve the object id first.',
|
||||
),
|
||||
includeFullSystemFields: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
@@ -134,7 +134,36 @@ const CreateManyRelationFieldsInputSchema = z.object({
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataToolsFactory {
|
||||
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
|
||||
constructor(
|
||||
private readonly fieldMetadataService: FieldMetadataService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
) {}
|
||||
|
||||
private async getObjectMetadataIdOrThrow(
|
||||
workspaceId: string,
|
||||
objectName: string,
|
||||
): Promise<string> {
|
||||
const { flatObjectMetadataMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatObjectMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const objectMetadataId = getObjectMetadataIdByName({
|
||||
flatObjectMetadataMaps,
|
||||
objectName,
|
||||
});
|
||||
|
||||
if (!isDefined(objectMetadataId)) {
|
||||
throw new Error(
|
||||
`Object "${objectName}" not found. Use get_object_metadata to list available objects.`,
|
||||
);
|
||||
}
|
||||
|
||||
return objectMetadataId;
|
||||
}
|
||||
|
||||
generateTools(workspaceId: string): ToolSet {
|
||||
return {
|
||||
@@ -145,16 +174,26 @@ export class FieldMetadataToolsFactory {
|
||||
execute: async (parameters: {
|
||||
id?: string;
|
||||
objectMetadataId?: string;
|
||||
objectName?: string;
|
||||
includeFullSystemFields?: boolean;
|
||||
limit?: number;
|
||||
}) => {
|
||||
const objectMetadataId =
|
||||
parameters.objectMetadataId ??
|
||||
(parameters.objectName
|
||||
? await this.getObjectMetadataIdOrThrow(
|
||||
workspaceId,
|
||||
parameters.objectName,
|
||||
)
|
||||
: undefined);
|
||||
|
||||
const rawResults = await this.fieldMetadataService.query({
|
||||
filter: {
|
||||
workspaceId: { eq: workspaceId },
|
||||
...(parameters.id ? { id: { eq: parameters.id } } : {}),
|
||||
...(parameters.objectMetadataId
|
||||
...(isDefined(objectMetadataId)
|
||||
? {
|
||||
objectMetadataId: { eq: parameters.objectMetadataId },
|
||||
objectMetadataId: { eq: objectMetadataId },
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
@@ -164,7 +203,10 @@ export class FieldMetadataToolsFactory {
|
||||
const compactedFields = (
|
||||
rawResults as unknown as Record<string, unknown>[]
|
||||
)
|
||||
.filter((field) => !EXCLUDED_FIELD_NAMES.has(field.name as string))
|
||||
.filter(
|
||||
(field) =>
|
||||
!METADATA_TOOL_EXCLUDED_FIELD_NAMES.has(field.name as string),
|
||||
)
|
||||
.map((field) => {
|
||||
if (field.isSystem && !parameters.includeFullSystemFields) {
|
||||
return {
|
||||
|
||||
+103
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const buildFieldByObjectIdAndNameKey = (
|
||||
objectMetadataId: string,
|
||||
fieldName: string,
|
||||
): string => `${objectMetadataId}:${fieldName}`;
|
||||
+38
@@ -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 };
|
||||
};
|
||||
+72
@@ -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),
|
||||
);
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
|
||||
|
||||
export const getObjectMetadataIdByName = ({
|
||||
flatObjectMetadataMaps,
|
||||
objectName,
|
||||
}: {
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
objectName: string;
|
||||
}): string | undefined => {
|
||||
const { idByNameSingular, idByNamePlural } = buildObjectIdByNameMaps(
|
||||
flatObjectMetadataMaps,
|
||||
);
|
||||
|
||||
return idByNameSingular[objectName] ?? idByNamePlural[objectName];
|
||||
};
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
type ViewFilterOperand,
|
||||
type ViewSortDirection,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
||||
import { type FlatEntityToCreateDeleteUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-to-create-delete-update.type';
|
||||
import { fromCreateViewFieldInputToFlatViewFieldToCreate } from 'src/engine/metadata-modules/flat-view-field/utils/from-create-view-field-input-to-flat-view-field-to-create.util';
|
||||
import { fromCreateViewFilterInputToFlatViewFilterToCreate } from 'src/engine/metadata-modules/flat-view-filter/utils/from-create-view-filter-input-to-flat-view-filter-to-create.util';
|
||||
import { fromCreateViewSortInputToFlatViewSortToCreate } from 'src/engine/metadata-modules/flat-view-sort/utils/from-create-view-sort-input-to-flat-view-sort-to-create.util';
|
||||
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
|
||||
|
||||
// Resolved children specs (field names already resolved to fieldMetadataId)
|
||||
export type CompleteViewFieldSpec = {
|
||||
fieldMetadataId: string;
|
||||
isVisible: boolean;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type CompleteViewFilterSpec = {
|
||||
fieldMetadataId: string;
|
||||
operand: ViewFilterOperand;
|
||||
value: ViewFilterValue;
|
||||
subFieldName?: string;
|
||||
};
|
||||
|
||||
export type CompleteViewSortSpec = {
|
||||
fieldMetadataId: string;
|
||||
direction: ViewSortDirection;
|
||||
};
|
||||
|
||||
type BuildCompleteViewChildrenFlatOperationsArgs = {
|
||||
viewId: string;
|
||||
flatApplication: FlatApplication;
|
||||
fields?: CompleteViewFieldSpec[];
|
||||
filters?: CompleteViewFilterSpec[];
|
||||
sorts?: CompleteViewSortSpec[];
|
||||
} & Pick<
|
||||
AllFlatEntityMaps,
|
||||
| 'flatFieldMetadataMaps'
|
||||
| 'flatViewMaps'
|
||||
| 'flatViewFieldMaps'
|
||||
| 'flatViewFilterMaps'
|
||||
| 'flatViewSortMaps'
|
||||
| 'flatViewFieldGroupMaps'
|
||||
| 'flatViewFilterGroupMaps'
|
||||
>;
|
||||
|
||||
type CompleteViewChildrenFlatOperations = {
|
||||
viewField?: FlatEntityToCreateDeleteUpdate<'viewField'>;
|
||||
viewFilter?: FlatEntityToCreateDeleteUpdate<'viewFilter'>;
|
||||
viewSort?: FlatEntityToCreateDeleteUpdate<'viewSort'>;
|
||||
};
|
||||
|
||||
export const buildCompleteViewChildrenFlatOperations = ({
|
||||
viewId,
|
||||
flatApplication,
|
||||
flatFieldMetadataMaps,
|
||||
flatViewMaps,
|
||||
flatViewFieldMaps,
|
||||
flatViewFilterMaps,
|
||||
flatViewSortMaps,
|
||||
flatViewFieldGroupMaps,
|
||||
flatViewFilterGroupMaps,
|
||||
fields,
|
||||
filters,
|
||||
sorts,
|
||||
}: BuildCompleteViewChildrenFlatOperationsArgs): CompleteViewChildrenFlatOperations => {
|
||||
const operations: CompleteViewChildrenFlatOperations = {};
|
||||
|
||||
if (isDefined(fields)) {
|
||||
const existingFlatViewFields = Object.values(
|
||||
flatViewFieldMaps.byUniversalIdentifier,
|
||||
)
|
||||
.filter(isDefined)
|
||||
.filter((flatViewField) => flatViewField.viewId === viewId);
|
||||
|
||||
const flatViewFieldsToCreate = fields.map((field, index) =>
|
||||
fromCreateViewFieldInputToFlatViewFieldToCreate({
|
||||
createViewFieldInput: {
|
||||
viewId,
|
||||
fieldMetadataId: field.fieldMetadataId,
|
||||
isVisible: field.isVisible,
|
||||
size: field.size,
|
||||
position: index,
|
||||
},
|
||||
flatApplication,
|
||||
flatFieldMetadataMaps,
|
||||
flatViewMaps,
|
||||
flatViewFieldGroupMaps,
|
||||
}),
|
||||
);
|
||||
|
||||
operations.viewField = {
|
||||
flatEntityToCreate: flatViewFieldsToCreate,
|
||||
flatEntityToDelete: existingFlatViewFields,
|
||||
flatEntityToUpdate: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (isDefined(filters)) {
|
||||
const existingFlatViewFilters = Object.values(
|
||||
flatViewFilterMaps.byUniversalIdentifier,
|
||||
)
|
||||
.filter(isDefined)
|
||||
.filter((flatViewFilter) => flatViewFilter.viewId === viewId);
|
||||
|
||||
const flatViewFiltersToCreate = filters.map((filter) =>
|
||||
fromCreateViewFilterInputToFlatViewFilterToCreate({
|
||||
createViewFilterInput: {
|
||||
viewId,
|
||||
fieldMetadataId: filter.fieldMetadataId,
|
||||
operand: filter.operand,
|
||||
value: filter.value,
|
||||
subFieldName: filter.subFieldName,
|
||||
},
|
||||
flatApplication,
|
||||
flatFieldMetadataMaps,
|
||||
flatViewMaps,
|
||||
flatViewFilterGroupMaps,
|
||||
}),
|
||||
);
|
||||
|
||||
operations.viewFilter = {
|
||||
flatEntityToCreate: flatViewFiltersToCreate,
|
||||
flatEntityToDelete: existingFlatViewFilters,
|
||||
flatEntityToUpdate: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (isDefined(sorts)) {
|
||||
const existingFlatViewSorts = Object.values(
|
||||
flatViewSortMaps.byUniversalIdentifier,
|
||||
)
|
||||
.filter(isDefined)
|
||||
.filter((flatViewSort) => flatViewSort.viewId === viewId);
|
||||
|
||||
const flatViewSortsToCreate = sorts.map((sort) =>
|
||||
fromCreateViewSortInputToFlatViewSortToCreate({
|
||||
createViewSortInput: {
|
||||
viewId,
|
||||
fieldMetadataId: sort.fieldMetadataId,
|
||||
direction: sort.direction,
|
||||
},
|
||||
flatApplication,
|
||||
flatFieldMetadataMaps,
|
||||
flatViewMaps,
|
||||
}),
|
||||
);
|
||||
|
||||
operations.viewSort = {
|
||||
flatEntityToCreate: flatViewSortsToCreate,
|
||||
flatEntityToDelete: existingFlatViewSorts,
|
||||
flatEntityToUpdate: [],
|
||||
};
|
||||
}
|
||||
|
||||
return operations;
|
||||
};
|
||||
+2
-1
@@ -9,8 +9,8 @@ import {
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
@@ -48,6 +48,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
FeatureFlagModule,
|
||||
ApplicationModule,
|
||||
ApplicationTranslationModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
TypeORMModule,
|
||||
|
||||
+11
-4
@@ -989,12 +989,19 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
workspaceId: string,
|
||||
options?: FindManyOptions<ObjectMetadataEntity>,
|
||||
): Promise<FlatObjectMetadata[]> {
|
||||
const whereWithWorkspaceId = Array.isArray(options?.where)
|
||||
? options.where.map((whereCondition) => ({
|
||||
...whereCondition,
|
||||
workspaceId,
|
||||
}))
|
||||
: {
|
||||
...options?.where,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
const objectMetadataEntities = await this.objectMetadataRepository.find({
|
||||
...options,
|
||||
where: {
|
||||
...options?.where,
|
||||
workspaceId,
|
||||
},
|
||||
where: whereWithWorkspaceId,
|
||||
order: {
|
||||
...options?.order,
|
||||
},
|
||||
|
||||
+88
-7
@@ -1,14 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import { type FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant';
|
||||
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
|
||||
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
|
||||
type InlinedObjectFieldSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FieldMetadataType;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const OBJECT_STRIP_WHEN_NULLISH = [
|
||||
'overrides',
|
||||
'color',
|
||||
@@ -20,11 +31,19 @@ const OBJECT_STRIP_WHEN_NULLISH = [
|
||||
];
|
||||
|
||||
const GetObjectMetadataInputSchema = z.object({
|
||||
id: z
|
||||
id: z.uuid().optional().describe('Object ID. Returns one object if set.'),
|
||||
objectName: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Object ID. Returns one object if set.'),
|
||||
.describe(
|
||||
'Filter by object name, singular or plural (e.g. "opportunity" or "opportunities"). Lets you locate an object by name in one call without scanning the full list.',
|
||||
),
|
||||
includeFields: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
'When true, each returned object includes its fields as a compact array of {id, name, type, label}. Use this to fetch an object and all the field ids you need in a single call (e.g. before building a dashboard) instead of a separate get_field_metadata call.',
|
||||
),
|
||||
includeFullSystemObjects: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
@@ -103,16 +122,59 @@ const UpdateManyObjectMetadataInputSchema = z.object({
|
||||
|
||||
@Injectable()
|
||||
export class ObjectMetadataToolsFactory {
|
||||
constructor(private readonly objectMetadataService: ObjectMetadataService) {}
|
||||
constructor(
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
) {}
|
||||
|
||||
private async buildFieldsByObjectId(
|
||||
workspaceId: string,
|
||||
): Promise<Map<string, InlinedObjectFieldSummary[]>> {
|
||||
const { flatFieldMetadataMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatFieldMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const fieldsByObjectId = new Map<string, InlinedObjectFieldSummary[]>();
|
||||
|
||||
for (const fieldMetadata of Object.values(
|
||||
flatFieldMetadataMaps.byUniversalIdentifier,
|
||||
)) {
|
||||
if (
|
||||
!isDefined(fieldMetadata) ||
|
||||
METADATA_TOOL_EXCLUDED_FIELD_NAMES.has(fieldMetadata.name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing =
|
||||
fieldsByObjectId.get(fieldMetadata.objectMetadataId) ?? [];
|
||||
|
||||
existing.push({
|
||||
id: fieldMetadata.id,
|
||||
name: fieldMetadata.name,
|
||||
type: fieldMetadata.type,
|
||||
label: fieldMetadata.label ?? fieldMetadata.name,
|
||||
});
|
||||
fieldsByObjectId.set(fieldMetadata.objectMetadataId, existing);
|
||||
}
|
||||
|
||||
return fieldsByObjectId;
|
||||
}
|
||||
|
||||
generateTools(workspaceId: string): ToolSet {
|
||||
return {
|
||||
get_object_metadata: {
|
||||
description:
|
||||
"List object metadata as an array. System objects are returned as compact {id, nameSingular, namePlural} — enough to locate an object by name and read its id. Keep includeFullSystemObjects at its default (false); only set it true when you specifically need a system object's full configuration.",
|
||||
"List object metadata as an array. Filter to a single object by id or objectName (singular or plural). Set includeFields to also return each object's fields ({id, name, type, label}) — enough to build a dashboard or view without a separate get_field_metadata call. System objects are otherwise returned as compact {id, nameSingular, namePlural}. Keep includeFullSystemObjects at its default (false); only set it true when you specifically need a system object's full configuration.",
|
||||
inputSchema: GetObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
id?: string;
|
||||
objectName?: string;
|
||||
includeFields?: boolean;
|
||||
includeFullSystemObjects?: boolean;
|
||||
limit?: number;
|
||||
}) => {
|
||||
@@ -120,25 +182,44 @@ export class ObjectMetadataToolsFactory {
|
||||
await this.objectMetadataService.findManyWithinWorkspace(
|
||||
workspaceId,
|
||||
{
|
||||
...(parameters.id ? { where: { id: parameters.id } } : {}),
|
||||
...(parameters.id
|
||||
? { where: { id: parameters.id } }
|
||||
: parameters.objectName
|
||||
? {
|
||||
where: [
|
||||
{ nameSingular: parameters.objectName },
|
||||
{ namePlural: parameters.objectName },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
take: parameters.limit ?? 100,
|
||||
},
|
||||
);
|
||||
|
||||
const fieldsByObjectId = parameters.includeFields
|
||||
? await this.buildFieldsByObjectId(workspaceId)
|
||||
: undefined;
|
||||
|
||||
return flatObjectMetadatas.map((flatObjectMetadata) => {
|
||||
const dto =
|
||||
fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata);
|
||||
|
||||
const fields = fieldsByObjectId?.get(dto.id) ?? [];
|
||||
|
||||
if (dto.isSystem && !parameters.includeFullSystemObjects) {
|
||||
return {
|
||||
id: dto.id,
|
||||
nameSingular: dto.nameSingular,
|
||||
namePlural: dto.namePlural,
|
||||
...(parameters.includeFields ? { fields } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return compactMetadataOutput(
|
||||
{ ...dto },
|
||||
{
|
||||
...dto,
|
||||
...(parameters.includeFields ? { fields } : {}),
|
||||
},
|
||||
{ stripWhenNullish: OBJECT_STRIP_WHEN_NULLISH },
|
||||
);
|
||||
});
|
||||
|
||||
+58
-1
@@ -1,16 +1,23 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
type ChartRecordFilter,
|
||||
type ViewFilterOperand,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { getInvalidSelectFilterOptionValues } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { PageLayoutWidgetFieldValidationException } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget-field-validation.exception';
|
||||
import {
|
||||
PageLayoutWidgetException,
|
||||
PageLayoutWidgetExceptionCode,
|
||||
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
|
||||
import { PageLayoutWidgetFieldValidationException } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget-field-validation.exception';
|
||||
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
|
||||
import { findActiveFlatFieldMetadataById } from 'src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util';
|
||||
import { isChartReferencingFieldInConfiguration } from 'src/engine/metadata-modules/page-layout-widget/utils/is-chart-referencing-field-in-configuration.util';
|
||||
@@ -47,6 +54,45 @@ const validateGroupByFieldAsChartFieldOrThrow = (
|
||||
}
|
||||
};
|
||||
|
||||
const validateSelectFilterOptionsOrThrow = ({
|
||||
recordFilter,
|
||||
filterField,
|
||||
widgetTitle,
|
||||
}: {
|
||||
recordFilter: ChartRecordFilter;
|
||||
filterField: FlatFieldMetadata<
|
||||
FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT
|
||||
>;
|
||||
widgetTitle?: string | null;
|
||||
}): void => {
|
||||
if (!isDefined(recordFilter.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidValues = getInvalidSelectFilterOptionValues({
|
||||
fieldMetadata: filterField,
|
||||
operand: recordFilter.operand as ViewFilterOperand,
|
||||
value: recordFilter.value,
|
||||
});
|
||||
|
||||
if (invalidValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidValuesText = invalidValues
|
||||
.map((value) => `"${value}"`)
|
||||
.join(', ');
|
||||
const allowedValuesText = filterField.options
|
||||
?.map((option) => option.value)
|
||||
.map((optionValue) => `"${optionValue}"`)
|
||||
.join(', ');
|
||||
|
||||
throw buildChartFieldValidationException(
|
||||
`Filter on "${filterField.label}" uses option(s) ${invalidValuesText} that do not exist. Allowed values: ${allowedValuesText}.`,
|
||||
widgetTitle,
|
||||
);
|
||||
};
|
||||
|
||||
export const validateChartConfigurationFieldReferencesOrThrow = ({
|
||||
widgetConfiguration,
|
||||
widgetObjectMetadataId,
|
||||
@@ -211,6 +257,17 @@ export const validateChartConfigurationFieldReferencesOrThrow = ({
|
||||
widgetTitle,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isFlatFieldMetadataOfType(filterField, FieldMetadataType.SELECT) ||
|
||||
isFlatFieldMetadataOfType(filterField, FieldMetadataType.MULTI_SELECT)
|
||||
) {
|
||||
validateSelectFilterOptionsOrThrow({
|
||||
recordFilter,
|
||||
filterField,
|
||||
widgetTitle,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+263
-4
@@ -10,6 +10,9 @@ import {
|
||||
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
|
||||
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
|
||||
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
|
||||
import { CompleteViewUpsertService } from 'src/engine/metadata-modules/view/tools/services/complete-view-upsert.service';
|
||||
import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service';
|
||||
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
|
||||
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
|
||||
@@ -17,9 +20,9 @@ import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-to
|
||||
describe('ViewToolsFactory', () => {
|
||||
let viewToolsFactory: ViewToolsFactory;
|
||||
let viewService: jest.Mocked<ViewService>;
|
||||
let completeViewUpsertService: jest.Mocked<CompleteViewUpsertService>;
|
||||
let viewFieldService: jest.Mocked<ViewFieldService>;
|
||||
let viewQueryParamsService: jest.Mocked<ViewQueryParamsService>;
|
||||
let _flatEntityMapsCacheService: jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>;
|
||||
|
||||
const mockWorkspaceId = 'workspace-id';
|
||||
const mockUserWorkspaceId = 'user-workspace-id';
|
||||
@@ -99,15 +102,40 @@ describe('ViewToolsFactory', () => {
|
||||
findByWorkspaceId: jest.fn(),
|
||||
findByObjectMetadataId: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByIdWithRelations: jest.fn(),
|
||||
createOne: jest.fn(),
|
||||
updateOne: jest.fn(),
|
||||
deleteOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CompleteViewUpsertService,
|
||||
useValue: {
|
||||
upsertCompleteView: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ViewFieldService,
|
||||
useValue: {
|
||||
createMany: jest.fn().mockResolvedValue([]),
|
||||
findByViewId: jest.fn().mockResolvedValue([]),
|
||||
deleteOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ViewFilterService,
|
||||
useValue: {
|
||||
createOne: jest.fn(),
|
||||
findByViewId: jest.fn().mockResolvedValue([]),
|
||||
deleteOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ViewSortService,
|
||||
useValue: {
|
||||
createOne: jest.fn(),
|
||||
findByViewId: jest.fn().mockResolvedValue([]),
|
||||
deleteOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -130,11 +158,9 @@ describe('ViewToolsFactory', () => {
|
||||
|
||||
viewToolsFactory = module.get<ViewToolsFactory>(ViewToolsFactory);
|
||||
viewService = module.get(ViewService);
|
||||
completeViewUpsertService = module.get(CompleteViewUpsertService);
|
||||
viewFieldService = module.get(ViewFieldService);
|
||||
viewQueryParamsService = module.get(ViewQueryParamsService);
|
||||
_flatEntityMapsCacheService = module.get(
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -579,6 +605,239 @@ describe('ViewToolsFactory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsert_complete_view tool', () => {
|
||||
it('should be generated alongside the other write tools', () => {
|
||||
const tools = viewToolsFactory.generateWriteTools(mockWorkspaceId);
|
||||
|
||||
expect(tools).toHaveProperty('upsert_complete_view');
|
||||
expect(tools['upsert_complete_view']).toHaveProperty('description');
|
||||
expect(tools['upsert_complete_view']).toHaveProperty('inputSchema');
|
||||
expect(tools['upsert_complete_view']).toHaveProperty('execute');
|
||||
});
|
||||
|
||||
it('should create a view with fields, filters, and sorts referenced by name in a single upsert call', async () => {
|
||||
completeViewUpsertService.upsertCompleteView.mockResolvedValue({
|
||||
id: 'new-view-id',
|
||||
name: 'Pipeline',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: ViewType.TABLE,
|
||||
icon: 'IconList',
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
viewFields: [{}, {}],
|
||||
viewFilters: [{}],
|
||||
viewSorts: [{}],
|
||||
} as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
const result = await callExecute(tools['upsert_complete_view'], {
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
name: 'Pipeline',
|
||||
type: ViewType.TABLE,
|
||||
fields: [{ fieldName: 'name' }, { fieldName: 'stage' }],
|
||||
filters: [{ fieldName: 'stage', operand: 'IS_NOT', value: ['WON'] }],
|
||||
sorts: [{ fieldName: 'name', direction: 'DESC' }],
|
||||
});
|
||||
|
||||
expect(viewService.createOne).not.toHaveBeenCalled();
|
||||
expect(
|
||||
completeViewUpsertService.upsertCompleteView,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
existingViewId: undefined,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
name: 'Pipeline',
|
||||
type: ViewType.TABLE,
|
||||
fields: [
|
||||
{
|
||||
fieldMetadataId: mockNameFieldMetadataId,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: mockStageFieldMetadataId,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
fieldMetadataId: mockStageFieldMetadataId,
|
||||
operand: 'IS_NOT',
|
||||
value: ['WON'],
|
||||
subFieldName: undefined,
|
||||
},
|
||||
],
|
||||
sorts: [
|
||||
{ fieldMetadataId: mockNameFieldMetadataId, direction: 'DESC' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
id: 'new-view-id',
|
||||
name: 'Pipeline',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: ViewType.TABLE,
|
||||
icon: 'IconList',
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
fieldCount: 2,
|
||||
filterCount: 1,
|
||||
sortCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept a field referenced by fieldMetadataId without name resolution', async () => {
|
||||
completeViewUpsertService.upsertCompleteView.mockResolvedValue({
|
||||
id: 'new-view-id',
|
||||
name: 'By Id',
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
type: ViewType.TABLE,
|
||||
icon: 'IconList',
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
viewFields: [{}],
|
||||
} as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await callExecute(tools['upsert_complete_view'], {
|
||||
objectNameSingular: mockObjectNameSingular,
|
||||
name: 'By Id',
|
||||
fields: [{ fieldMetadataId: mockStageFieldMetadataId }],
|
||||
});
|
||||
|
||||
expect(
|
||||
completeViewUpsertService.upsertCompleteView,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fields: [
|
||||
{
|
||||
fieldMetadataId: mockStageFieldMetadataId,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require objectNameSingular when creating', async () => {
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await expect(
|
||||
callExecute(tools['upsert_complete_view'], {
|
||||
name: 'Missing object',
|
||||
fields: [{ fieldName: 'name' }],
|
||||
}),
|
||||
).rejects.toThrow('objectNameSingular is required');
|
||||
});
|
||||
|
||||
it('should delegate filter replacement to upsertCompleteView when updating', async () => {
|
||||
const existingView = {
|
||||
...mockView,
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
};
|
||||
|
||||
viewService.findById.mockResolvedValue(existingView as any);
|
||||
completeViewUpsertService.upsertCompleteView.mockResolvedValue({
|
||||
...existingView,
|
||||
viewFilters: [{}],
|
||||
} as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await callExecute(tools['upsert_complete_view'], {
|
||||
id: mockViewId,
|
||||
filters: [{ fieldName: 'stage', operand: 'IS', value: ['WON'] }],
|
||||
});
|
||||
|
||||
expect(
|
||||
completeViewUpsertService.upsertCompleteView,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
existingViewId: mockViewId,
|
||||
objectMetadataId: mockObjectMetadataId,
|
||||
filters: [
|
||||
{
|
||||
fieldMetadataId: mockStageFieldMetadataId,
|
||||
operand: 'IS',
|
||||
value: ['WON'],
|
||||
subFieldName: undefined,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(viewService.createOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass an empty sorts array through to upsertCompleteView on update', async () => {
|
||||
const existingView = {
|
||||
...mockView,
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
};
|
||||
|
||||
viewService.findById.mockResolvedValue(existingView as any);
|
||||
completeViewUpsertService.upsertCompleteView.mockResolvedValue({
|
||||
...existingView,
|
||||
viewSorts: [],
|
||||
} as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await callExecute(tools['upsert_complete_view'], {
|
||||
id: mockViewId,
|
||||
sorts: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
completeViewUpsertService.upsertCompleteView,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
existingViewId: mockViewId,
|
||||
sorts: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject updating another users unlisted view', async () => {
|
||||
const existingView = {
|
||||
...mockView,
|
||||
visibility: ViewVisibility.UNLISTED,
|
||||
createdByUserWorkspaceId: 'other-user-workspace-id',
|
||||
};
|
||||
|
||||
viewService.findById.mockResolvedValue(existingView as any);
|
||||
|
||||
const tools = viewToolsFactory.generateWriteTools(
|
||||
mockWorkspaceId,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
await expect(
|
||||
callExecute(tools['upsert_complete_view'], {
|
||||
id: mockViewId,
|
||||
name: 'Updated',
|
||||
}),
|
||||
).rejects.toThrow('You can only update your own unlisted views');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete-view tool', () => {
|
||||
it('should delete a workspace view', async () => {
|
||||
const existingView = {
|
||||
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
AggregateOperations,
|
||||
ViewCalendarLayout,
|
||||
ViewType,
|
||||
ViewVisibility,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
||||
import { type FlatEntityToCreateDeleteUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-to-create-delete-update.type';
|
||||
import {
|
||||
buildCompleteViewChildrenFlatOperations,
|
||||
type CompleteViewFieldSpec,
|
||||
type CompleteViewFilterSpec,
|
||||
type CompleteViewSortSpec,
|
||||
} from 'src/engine/metadata-modules/flat-view/utils/build-complete-view-children-flat-operations.util';
|
||||
import { fromCreateViewInputToFlatViewToCreate } from 'src/engine/metadata-modules/flat-view/utils/from-create-view-input-to-flat-view-to-create.util';
|
||||
import { fromUpdateViewInputToFlatViewToUpdateOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-update-view-input-to-flat-view-to-update-or-throw.util';
|
||||
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
|
||||
import {
|
||||
ViewException,
|
||||
ViewExceptionCode,
|
||||
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
|
||||
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
type ViewUpsertFlatEntityOperations = {
|
||||
view?: FlatEntityToCreateDeleteUpdate<'view'>;
|
||||
viewGroup?: FlatEntityToCreateDeleteUpdate<'viewGroup'>;
|
||||
viewField?: FlatEntityToCreateDeleteUpdate<'viewField'>;
|
||||
viewFilter?: FlatEntityToCreateDeleteUpdate<'viewFilter'>;
|
||||
viewSort?: FlatEntityToCreateDeleteUpdate<'viewSort'>;
|
||||
};
|
||||
|
||||
type ViewUpsertRootOperations = Pick<
|
||||
ViewUpsertFlatEntityOperations,
|
||||
'view' | 'viewGroup'
|
||||
> & {
|
||||
viewId: string;
|
||||
viewUniversalIdentifier?: string;
|
||||
};
|
||||
|
||||
// Backs the AI `upsert_complete_view` tool: upserts a view together with its
|
||||
// fields, filters and sorts in a single workspace migration.
|
||||
@Injectable()
|
||||
export class CompleteViewUpsertService {
|
||||
constructor(
|
||||
private readonly viewService: ViewService,
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
) {}
|
||||
|
||||
async upsertCompleteView({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
existingViewId,
|
||||
objectMetadataId,
|
||||
name,
|
||||
icon,
|
||||
type,
|
||||
visibility,
|
||||
mainGroupByFieldMetadataId,
|
||||
kanbanAggregateOperation,
|
||||
kanbanAggregateOperationFieldMetadataId,
|
||||
calendarLayout,
|
||||
calendarFieldMetadataId,
|
||||
fields,
|
||||
filters,
|
||||
sorts,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
userWorkspaceId?: string;
|
||||
existingViewId?: string;
|
||||
objectMetadataId?: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
type?: ViewType;
|
||||
visibility?: ViewVisibility;
|
||||
mainGroupByFieldMetadataId?: string;
|
||||
kanbanAggregateOperation?: AggregateOperations;
|
||||
kanbanAggregateOperationFieldMetadataId?: string;
|
||||
calendarLayout?: ViewCalendarLayout;
|
||||
calendarFieldMetadataId?: string;
|
||||
fields?: CompleteViewFieldSpec[];
|
||||
filters?: CompleteViewFilterSpec[];
|
||||
sorts?: CompleteViewSortSpec[];
|
||||
}): Promise<ViewDTO> {
|
||||
const { workspaceCustomFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const applicationUniversalIdentifier =
|
||||
workspaceCustomFlatApplication.universalIdentifier;
|
||||
|
||||
const {
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
flatViewMaps,
|
||||
flatViewGroupMaps,
|
||||
flatViewFieldMaps,
|
||||
flatViewFilterMaps,
|
||||
flatViewSortMaps,
|
||||
flatViewFieldGroupMaps,
|
||||
flatViewFilterGroupMaps,
|
||||
} =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: [
|
||||
'flatFieldMetadataMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
'flatViewMaps',
|
||||
'flatViewGroupMaps',
|
||||
'flatViewFieldMaps',
|
||||
'flatViewFilterMaps',
|
||||
'flatViewSortMaps',
|
||||
'flatViewFieldGroupMaps',
|
||||
'flatViewFilterGroupMaps',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const isCreatingView = !isDefined(existingViewId);
|
||||
|
||||
const rootOperations = isDefined(existingViewId)
|
||||
? this.buildUpdateViewRootOperationsOrThrow({
|
||||
existingViewId,
|
||||
name,
|
||||
icon,
|
||||
userWorkspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
flatViewMaps,
|
||||
flatViewGroupMaps,
|
||||
flatFieldMetadataMaps,
|
||||
})
|
||||
: this.buildCreateViewRootOperationsOrThrow({
|
||||
objectMetadataId,
|
||||
name,
|
||||
icon,
|
||||
type,
|
||||
visibility,
|
||||
mainGroupByFieldMetadataId,
|
||||
kanbanAggregateOperation,
|
||||
kanbanAggregateOperationFieldMetadataId,
|
||||
calendarLayout,
|
||||
calendarFieldMetadataId,
|
||||
userWorkspaceId,
|
||||
flatApplication: workspaceCustomFlatApplication,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
const { viewId, viewUniversalIdentifier } = rootOperations;
|
||||
|
||||
const flatViewMapsForChildren =
|
||||
isCreatingView && isDefined(viewUniversalIdentifier)
|
||||
? {
|
||||
...flatViewMaps,
|
||||
universalIdentifierById: {
|
||||
...flatViewMaps.universalIdentifierById,
|
||||
[viewId]: viewUniversalIdentifier,
|
||||
},
|
||||
}
|
||||
: flatViewMaps;
|
||||
|
||||
const childrenOperations = buildCompleteViewChildrenFlatOperations({
|
||||
viewId,
|
||||
flatApplication: workspaceCustomFlatApplication,
|
||||
flatFieldMetadataMaps,
|
||||
flatViewMaps: flatViewMapsForChildren,
|
||||
flatViewFieldMaps,
|
||||
flatViewFilterMaps,
|
||||
flatViewSortMaps,
|
||||
flatViewFieldGroupMaps,
|
||||
flatViewFilterGroupMaps,
|
||||
fields,
|
||||
filters,
|
||||
sorts,
|
||||
});
|
||||
|
||||
const allFlatEntityOperationByMetadataName: ViewUpsertFlatEntityOperations =
|
||||
{
|
||||
...(isDefined(rootOperations.view)
|
||||
? { view: rootOperations.view }
|
||||
: {}),
|
||||
...(isDefined(rootOperations.viewGroup)
|
||||
? { viewGroup: rootOperations.viewGroup }
|
||||
: {}),
|
||||
...(isDefined(childrenOperations.viewField)
|
||||
? { viewField: childrenOperations.viewField }
|
||||
: {}),
|
||||
...(isDefined(childrenOperations.viewFilter)
|
||||
? { viewFilter: childrenOperations.viewFilter }
|
||||
: {}),
|
||||
...(isDefined(childrenOperations.viewSort)
|
||||
? { viewSort: childrenOperations.viewSort }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName,
|
||||
workspaceId,
|
||||
isSystemBuild: false,
|
||||
applicationUniversalIdentifier,
|
||||
},
|
||||
);
|
||||
|
||||
if (validateAndBuildResult.status === 'fail') {
|
||||
throw new WorkspaceMigrationBuilderException(
|
||||
validateAndBuildResult,
|
||||
'Multiple validation errors occurred while upserting complete view',
|
||||
);
|
||||
}
|
||||
|
||||
const view = await this.viewService.findByIdWithRelations(
|
||||
viewId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(view)) {
|
||||
throw new ViewException(
|
||||
t`View not found after upsert`,
|
||||
ViewExceptionCode.VIEW_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
private buildCreateViewRootOperationsOrThrow({
|
||||
objectMetadataId,
|
||||
name,
|
||||
icon,
|
||||
type,
|
||||
visibility,
|
||||
mainGroupByFieldMetadataId,
|
||||
kanbanAggregateOperation,
|
||||
kanbanAggregateOperationFieldMetadataId,
|
||||
calendarLayout,
|
||||
calendarFieldMetadataId,
|
||||
userWorkspaceId,
|
||||
flatApplication,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
}: {
|
||||
objectMetadataId?: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
type?: ViewType;
|
||||
visibility?: ViewVisibility;
|
||||
mainGroupByFieldMetadataId?: string;
|
||||
kanbanAggregateOperation?: AggregateOperations;
|
||||
kanbanAggregateOperationFieldMetadataId?: string;
|
||||
calendarLayout?: ViewCalendarLayout;
|
||||
calendarFieldMetadataId?: string;
|
||||
userWorkspaceId?: string;
|
||||
flatApplication: FlatApplication;
|
||||
flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps'];
|
||||
flatObjectMetadataMaps: AllFlatEntityMaps['flatObjectMetadataMaps'];
|
||||
}): ViewUpsertRootOperations {
|
||||
if (!isDefined(objectMetadataId)) {
|
||||
throw new ViewException(
|
||||
t`ObjectMetadataId is required when creating a view`,
|
||||
ViewExceptionCode.INVALID_VIEW_DATA,
|
||||
);
|
||||
}
|
||||
|
||||
const { flatViewToCreate, flatViewGroupsToCreate } =
|
||||
fromCreateViewInputToFlatViewToCreate({
|
||||
createViewInput: {
|
||||
name: name ?? 'Untitled view',
|
||||
objectMetadataId,
|
||||
icon: icon ?? 'IconList',
|
||||
type: type ?? ViewType.TABLE,
|
||||
visibility: visibility ?? ViewVisibility.WORKSPACE,
|
||||
mainGroupByFieldMetadataId,
|
||||
kanbanAggregateOperation,
|
||||
kanbanAggregateOperationFieldMetadataId,
|
||||
calendarLayout,
|
||||
calendarFieldMetadataId,
|
||||
},
|
||||
createdByUserWorkspaceId: userWorkspaceId,
|
||||
flatApplication,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
return {
|
||||
viewId: flatViewToCreate.id,
|
||||
viewUniversalIdentifier: flatViewToCreate.universalIdentifier,
|
||||
view: {
|
||||
flatEntityToCreate: [flatViewToCreate],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
...(flatViewGroupsToCreate.length > 0
|
||||
? {
|
||||
viewGroup: {
|
||||
flatEntityToCreate: flatViewGroupsToCreate,
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildUpdateViewRootOperationsOrThrow({
|
||||
existingViewId,
|
||||
name,
|
||||
icon,
|
||||
userWorkspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
flatViewMaps,
|
||||
flatViewGroupMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}: {
|
||||
existingViewId: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
userWorkspaceId?: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
flatViewMaps: AllFlatEntityMaps['flatViewMaps'];
|
||||
flatViewGroupMaps: AllFlatEntityMaps['flatViewGroupMaps'];
|
||||
flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps'];
|
||||
}): ViewUpsertRootOperations {
|
||||
if (!isDefined(name) && !isDefined(icon)) {
|
||||
return { viewId: existingViewId };
|
||||
}
|
||||
|
||||
const { flatViewToUpdate, flatViewGroupsToDelete, flatViewGroupsToCreate } =
|
||||
fromUpdateViewInputToFlatViewToUpdateOrThrow({
|
||||
updateViewInput: { id: existingViewId, name, icon },
|
||||
flatViewMaps,
|
||||
flatViewGroupMaps,
|
||||
flatFieldMetadataMaps,
|
||||
userWorkspaceId,
|
||||
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
|
||||
workspaceCustomApplicationUniversalIdentifier:
|
||||
applicationUniversalIdentifier,
|
||||
});
|
||||
|
||||
return {
|
||||
viewId: existingViewId,
|
||||
view: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [flatViewToUpdate],
|
||||
},
|
||||
...(flatViewGroupsToCreate.length > 0 || flatViewGroupsToDelete.length > 0
|
||||
? {
|
||||
viewGroup: {
|
||||
flatEntityToCreate: flatViewGroupsToCreate,
|
||||
flatEntityToDelete: flatViewGroupsToDelete,
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
+428
-3
@@ -5,6 +5,8 @@ import {
|
||||
AggregateOperations,
|
||||
FieldMetadataType,
|
||||
ViewCalendarLayout,
|
||||
ViewFilterOperand,
|
||||
ViewSortDirection,
|
||||
ViewType,
|
||||
ViewVisibility,
|
||||
} from 'twenty-shared/types';
|
||||
@@ -12,13 +14,19 @@ import { z } from 'zod';
|
||||
|
||||
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
|
||||
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
|
||||
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
|
||||
|
||||
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
|
||||
import { CompleteViewUpsertService } from 'src/engine/metadata-modules/view/tools/services/complete-view-upsert.service';
|
||||
import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service';
|
||||
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
import { isFieldMetadataDateKind, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import {
|
||||
isDefined,
|
||||
isFieldMetadataDateKind,
|
||||
isNonEmptyArray,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
const GetViewsInputSchema = z.object({
|
||||
objectNameSingular: z
|
||||
@@ -64,7 +72,9 @@ const CreateViewInputSchema = z.object({
|
||||
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
|
||||
.optional()
|
||||
.default(ViewVisibility.WORKSPACE)
|
||||
.describe('View visibility'),
|
||||
.describe(
|
||||
'View visibility. ALWAYS prefer WORKSPACE (the default) — it is the right choice for shared views and for any view backing a dashboard widget, so the view is visible to everyone. Only use UNLISTED for a private, personal view explicitly requested by a single user, AND only when a user identity is available. An UNLISTED view created without an owner becomes invisible to everyone (e.g. its dashboard widget renders blank), so never use UNLISTED for widget-backing views.',
|
||||
),
|
||||
mainGroupByFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -115,10 +125,160 @@ const DeleteViewInputSchema = z.object({
|
||||
id: z.string().uuid().describe('View ID to delete'),
|
||||
});
|
||||
|
||||
const VIEW_FILTER_OPERAND_OPTIONS = Object.values(ViewFilterOperand);
|
||||
const VIEW_SORT_DIRECTION_OPTIONS = Object.values(ViewSortDirection);
|
||||
|
||||
const fieldReferenceShape = {
|
||||
fieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Field name (e.g. "amount", "stage"). Resolved to a UUID server-side. Provide this or fieldMetadataId.',
|
||||
),
|
||||
fieldMetadataId: z
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe(
|
||||
'Field UUID. Alternative to fieldName; takes precedence when both are given.',
|
||||
),
|
||||
};
|
||||
|
||||
const UpsertCompleteViewFieldSchema = z.object({
|
||||
...fieldReferenceShape,
|
||||
isVisible: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe('Whether the column is visible. Defaults to true.'),
|
||||
size: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.default(150)
|
||||
.describe('Column width. Defaults to 150.'),
|
||||
});
|
||||
|
||||
const UpsertCompleteViewFilterSchema = z.object({
|
||||
...fieldReferenceShape,
|
||||
operand: z
|
||||
.enum(VIEW_FILTER_OPERAND_OPTIONS)
|
||||
.describe(
|
||||
'Filter operator. Must be valid for the field type (e.g. SELECT: IS, IS_NOT; CURRENCY: GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL; TEXT: CONTAINS).',
|
||||
),
|
||||
value: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.string()),
|
||||
z.record(z.string(), z.unknown()),
|
||||
])
|
||||
.describe(
|
||||
'Filter value. Array of option values for SELECT/MULTI_SELECT (e.g. ["WON"]), number for NUMBER/CURRENCY, "" for IS_EMPTY/IS_NOT_EMPTY.',
|
||||
),
|
||||
subFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Required for composite fields (e.g. "amountMicros" for CURRENCY, "addressCity" for ADDRESS, "firstName" for FULL_NAME).',
|
||||
),
|
||||
});
|
||||
|
||||
const UpsertCompleteViewSortSchema = z.object({
|
||||
...fieldReferenceShape,
|
||||
direction: z
|
||||
.enum(VIEW_SORT_DIRECTION_OPTIONS)
|
||||
.optional()
|
||||
.default(ViewSortDirection.ASC)
|
||||
.describe('Sort direction: ASC or DESC. Defaults to ASC.'),
|
||||
});
|
||||
|
||||
const UpsertCompleteViewInputSchema = z.object({
|
||||
id: z
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('View ID to update. Omit to create a new view.'),
|
||||
objectNameSingular: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Object name this view is for (e.g. "opportunity"). Required when creating (no id); ignored when id is given.',
|
||||
),
|
||||
name: z.string().optional().describe('View name'),
|
||||
icon: z.string().optional().describe('Icon identifier (e.g. "IconList")'),
|
||||
type: z
|
||||
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
|
||||
.optional()
|
||||
.describe('View type. Defaults to TABLE on create.'),
|
||||
visibility: z
|
||||
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
|
||||
.optional()
|
||||
.describe(
|
||||
'View visibility. Defaults to WORKSPACE on create, which is almost always the best fit — it makes the view visible to everyone and is REQUIRED for any view backing a dashboard widget. Only set UNLISTED for a private, personal view a specific user explicitly asked for, AND only when a user identity is available. An UNLISTED view created without an owner is invisible to everyone (its dashboard widget renders blank), so never use UNLISTED for widget-backing views.',
|
||||
),
|
||||
mainGroupByFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Field name to group by (required for KANBAN, must be a SELECT field, e.g. "stage").',
|
||||
),
|
||||
kanbanAggregateOperation: z
|
||||
.enum(Object.values(AggregateOperations) as [string, ...string[]])
|
||||
.optional()
|
||||
.describe('Aggregate operation for kanban columns (e.g. "SUM", "COUNT").'),
|
||||
kanbanAggregateOperationFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Field name for the kanban aggregate operation (e.g. "amount").'),
|
||||
calendarLayout: z
|
||||
.enum([
|
||||
ViewCalendarLayout.DAY,
|
||||
ViewCalendarLayout.WEEK,
|
||||
ViewCalendarLayout.MONTH,
|
||||
])
|
||||
.optional()
|
||||
.describe('Calendar layout (required for CALENDAR).'),
|
||||
calendarFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Date field name for the calendar (required for CALENDAR, must be DATE or DATE_TIME).',
|
||||
),
|
||||
fields: z
|
||||
.array(UpsertCompleteViewFieldSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
'Declarative list of columns, in display order. Provided array REPLACES all existing fields; [] clears them; omit to leave untouched.',
|
||||
),
|
||||
filters: z
|
||||
.array(UpsertCompleteViewFilterSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
'Declarative list of filters. Provided array REPLACES all existing filters; [] clears them; omit to leave untouched.',
|
||||
),
|
||||
sorts: z
|
||||
.array(UpsertCompleteViewSortSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
'Declarative list of sorts. Provided array REPLACES all existing sorts; [] clears them; omit to leave untouched.',
|
||||
),
|
||||
});
|
||||
|
||||
type FieldReference = { fieldName?: string; fieldMetadataId?: string };
|
||||
|
||||
type UpsertCompleteViewIdentifiers = {
|
||||
existingViewId?: string;
|
||||
objectMetadataId: string;
|
||||
mainGroupByFieldMetadataId?: string;
|
||||
kanbanAggregateOperationFieldMetadataId?: string;
|
||||
calendarFieldMetadataId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ViewToolsFactory {
|
||||
constructor(
|
||||
private readonly viewService: ViewService,
|
||||
private readonly completeViewUpsertService: CompleteViewUpsertService,
|
||||
private readonly viewFieldService: ViewFieldService,
|
||||
private readonly viewQueryParamsService: ViewQueryParamsService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
@@ -253,6 +413,138 @@ export class ViewToolsFactory {
|
||||
return fieldMetadata.id;
|
||||
}
|
||||
|
||||
private async getFieldMetadataIdOrThrow(
|
||||
workspaceId: string,
|
||||
objectMetadataId: string,
|
||||
reference: FieldReference,
|
||||
): Promise<string> {
|
||||
if (isDefined(reference.fieldMetadataId)) {
|
||||
return reference.fieldMetadataId;
|
||||
}
|
||||
|
||||
if (isDefined(reference.fieldName)) {
|
||||
return this.resolveFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
reference.fieldName,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Each field, filter, and sort entry must provide either fieldName or fieldMetadataId.',
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveUpsertCompleteViewIdentifiersOrThrow({
|
||||
parameters,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
parameters: {
|
||||
id?: string;
|
||||
objectNameSingular?: string;
|
||||
type?: ViewType;
|
||||
mainGroupByFieldName?: string;
|
||||
kanbanAggregateOperationFieldName?: string;
|
||||
calendarLayout?: ViewCalendarLayout;
|
||||
calendarFieldName?: string;
|
||||
};
|
||||
workspaceId: string;
|
||||
userWorkspaceId?: string;
|
||||
}): Promise<UpsertCompleteViewIdentifiers> {
|
||||
if (isDefined(parameters.id)) {
|
||||
const existingView = await this.viewService.findById(
|
||||
parameters.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!existingView) {
|
||||
throw new Error(`View with id ${parameters.id} not found`);
|
||||
}
|
||||
|
||||
if (
|
||||
existingView.visibility === ViewVisibility.UNLISTED &&
|
||||
existingView.createdByUserWorkspaceId !== userWorkspaceId
|
||||
) {
|
||||
throw new Error('You can only update your own unlisted views');
|
||||
}
|
||||
|
||||
return {
|
||||
existingViewId: existingView.id,
|
||||
objectMetadataId: existingView.objectMetadataId,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDefined(parameters.objectNameSingular)) {
|
||||
throw new Error(
|
||||
'objectNameSingular is required when creating a view (no id provided).',
|
||||
);
|
||||
}
|
||||
|
||||
const objectMetadataId = await this.resolveObjectMetadataId(
|
||||
workspaceId,
|
||||
parameters.objectNameSingular,
|
||||
);
|
||||
|
||||
if (
|
||||
parameters.type === ViewType.KANBAN &&
|
||||
!isDefined(parameters.mainGroupByFieldName)
|
||||
) {
|
||||
throw new Error(
|
||||
'KANBAN views require mainGroupByFieldName. Provide a SELECT field name (e.g. "stage").',
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters.type === ViewType.CALENDAR) {
|
||||
if (!isDefined(parameters.calendarFieldName)) {
|
||||
throw new Error(
|
||||
'CALENDAR views require calendarFieldName (a DATE or DATE_TIME field name).',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(parameters.calendarLayout)) {
|
||||
throw new Error(
|
||||
'CALENDAR views require calendarLayout. Provide one of: "DAY", "WEEK", "MONTH".',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mainGroupByFieldMetadataId = isDefined(
|
||||
parameters.mainGroupByFieldName,
|
||||
)
|
||||
? await this.resolveGroupByFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
parameters.mainGroupByFieldName,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const kanbanAggregateOperationFieldMetadataId = isDefined(
|
||||
parameters.kanbanAggregateOperationFieldName,
|
||||
)
|
||||
? await this.resolveFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
parameters.kanbanAggregateOperationFieldName,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const calendarFieldMetadataId = isDefined(parameters.calendarFieldName)
|
||||
? await this.resolveCalendarFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
parameters.calendarFieldName,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
objectMetadataId,
|
||||
mainGroupByFieldMetadataId,
|
||||
kanbanAggregateOperationFieldMetadataId,
|
||||
calendarFieldMetadataId,
|
||||
};
|
||||
}
|
||||
|
||||
generateReadTools(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
@@ -317,6 +609,139 @@ export class ViewToolsFactory {
|
||||
|
||||
generateWriteTools(workspaceId: string, userWorkspaceId?: string): ToolSet {
|
||||
return {
|
||||
upsert_complete_view: {
|
||||
description: `Create or update a complete view — the view plus its fields (columns), filters, and sorts — in a single call.
|
||||
|
||||
IDENTITY: Omit "id" to CREATE a new view (requires objectNameSingular). Provide "id" to UPDATE an existing view.
|
||||
|
||||
FIELD REFERENCES: In fields/filters/sorts you can reference a field by NAME (fieldName, e.g. "amount") or by UUID (fieldMetadataId). Names are resolved server-side, so you usually do NOT need get_field_metadata first. UUID wins when both are given.
|
||||
|
||||
DECLARATIVE CHILDREN (replace semantics): fields, filters, and sorts each describe the FULL desired set.
|
||||
- A provided array REPLACES all existing entries of that kind (existing ones are deleted, the new ones created in order).
|
||||
- An empty array [] CLEARS all entries of that kind.
|
||||
- Omitting the key leaves existing entries untouched.
|
||||
This means you never need to fetch child ids to edit a view — just pass the desired end state. For surgical single-entry edits, the granular tools (create_view_filter, update_view_sort, etc.) remain available.
|
||||
|
||||
VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT field), CALENDAR (requires calendarFieldName + calendarLayout).`,
|
||||
inputSchema: UpsertCompleteViewInputSchema,
|
||||
execute: async (parameters: {
|
||||
id?: string;
|
||||
objectNameSingular?: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
type?: ViewType;
|
||||
visibility?: ViewVisibility;
|
||||
mainGroupByFieldName?: string;
|
||||
kanbanAggregateOperation?: AggregateOperations;
|
||||
kanbanAggregateOperationFieldName?: string;
|
||||
calendarLayout?: ViewCalendarLayout;
|
||||
calendarFieldName?: string;
|
||||
fields?: Array<
|
||||
FieldReference & { isVisible?: boolean; size?: number }
|
||||
>;
|
||||
filters?: Array<
|
||||
FieldReference & {
|
||||
operand: ViewFilterOperand;
|
||||
value: ViewFilterValue;
|
||||
subFieldName?: string;
|
||||
}
|
||||
>;
|
||||
sorts?: Array<FieldReference & { direction?: ViewSortDirection }>;
|
||||
}) => {
|
||||
try {
|
||||
const {
|
||||
existingViewId,
|
||||
objectMetadataId,
|
||||
mainGroupByFieldMetadataId,
|
||||
kanbanAggregateOperationFieldMetadataId,
|
||||
calendarFieldMetadataId,
|
||||
} = await this.resolveUpsertCompleteViewIdentifiersOrThrow({
|
||||
parameters,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
const fields = isDefined(parameters.fields)
|
||||
? await Promise.all(
|
||||
parameters.fields.map(async (field) => ({
|
||||
fieldMetadataId: await this.getFieldMetadataIdOrThrow(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
field,
|
||||
),
|
||||
isVisible: field.isVisible ?? true,
|
||||
size: field.size ?? 150,
|
||||
})),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const filters = isDefined(parameters.filters)
|
||||
? await Promise.all(
|
||||
parameters.filters.map(async (filter) => ({
|
||||
fieldMetadataId: await this.getFieldMetadataIdOrThrow(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
filter,
|
||||
),
|
||||
operand: filter.operand,
|
||||
value: filter.value,
|
||||
subFieldName: filter.subFieldName,
|
||||
})),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const sorts = isDefined(parameters.sorts)
|
||||
? await Promise.all(
|
||||
parameters.sorts.map(async (sort) => ({
|
||||
fieldMetadataId: await this.getFieldMetadataIdOrThrow(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
sort,
|
||||
),
|
||||
direction: sort.direction ?? ViewSortDirection.ASC,
|
||||
})),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const view =
|
||||
await this.completeViewUpsertService.upsertCompleteView({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
existingViewId,
|
||||
objectMetadataId,
|
||||
name: parameters.name,
|
||||
icon: parameters.icon,
|
||||
type: parameters.type,
|
||||
visibility: parameters.visibility,
|
||||
mainGroupByFieldMetadataId,
|
||||
kanbanAggregateOperation: parameters.kanbanAggregateOperation,
|
||||
kanbanAggregateOperationFieldMetadataId,
|
||||
calendarLayout: parameters.calendarLayout,
|
||||
calendarFieldMetadataId,
|
||||
fields,
|
||||
filters,
|
||||
sorts,
|
||||
});
|
||||
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
objectMetadataId,
|
||||
type: view.type,
|
||||
icon: view.icon,
|
||||
visibility: view.visibility,
|
||||
fieldCount: view.viewFields?.length ?? 0,
|
||||
filterCount: view.viewFilters?.length ?? 0,
|
||||
sortCount: view.viewSorts?.length ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
create_view: {
|
||||
description:
|
||||
'Create a new view for an object. Views define how records are displayed. For KANBAN views, mainGroupByFieldName is required and must be a SELECT field (e.g., "stage", "status"). For CALENDAR views, calendarFieldName and calendarLayout are required.',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-fie
|
||||
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
|
||||
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
|
||||
import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module';
|
||||
import { CompleteViewUpsertService } from 'src/engine/metadata-modules/view/tools/services/complete-view-upsert.service';
|
||||
import { ViewWidgetUpsertService } from 'src/engine/metadata-modules/view/services/view-widget-upsert.service';
|
||||
import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
@@ -43,6 +44,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
controllers: [ViewController],
|
||||
providers: [
|
||||
ViewService,
|
||||
CompleteViewUpsertService,
|
||||
ViewResolver,
|
||||
ViewQueryParamsService,
|
||||
ViewToolsFactory,
|
||||
|
||||
+70
-34
@@ -425,14 +425,38 @@ You help users create and manage dashboards with widgets.
|
||||
- add_dashboard_tab, add_dashboard_widget, update_dashboard_widget, delete_dashboard_widget
|
||||
- get_object_metadata / get_field_metadata (resolve object + field IDs)
|
||||
|
||||
## Graph Widget Workflow
|
||||
## Confirmation gate (ALWAYS ask before creating or updating)
|
||||
|
||||
1. Ask what data the user wants to visualize.
|
||||
2. Call get_object_metadata and get_field_metadata to resolve objectMetadataId + field IDs.
|
||||
3. Always call get_dashboard before modifying widgets.
|
||||
4. Build the widget configuration using the rules below.
|
||||
5. Call add_dashboard_widget or update_dashboard_widget. Use activeTabId from context if available.
|
||||
6. Call get_dashboard to verify the final configuration.
|
||||
Before calling ANY tool that creates or modifies a dashboard (\`create_complete_dashboard\`, \`add_dashboard_tab\`, \`add_dashboard_widget\`, \`update_dashboard_widget\`, \`delete_dashboard_widget\`), you MUST first present a short plan and get explicit user confirmation.
|
||||
|
||||
- Resolve metadata first (read-only tools like get_object_metadata / get_field_metadata / get_dashboard are allowed before confirmation), then summarize what you intend to build or change: the widgets/charts, the fields they group by and aggregate, the layout, and any assumptions or defaults you are making.
|
||||
- Ask the user to confirm (or adjust) and then STOP and wait for their answer. Do NOT call any creation/update tool in the same turn as the plan.
|
||||
- Only after the user confirms do you proceed to build/modify in the next turn.
|
||||
- Keep the plan concise — a few bullets, not an essay. The goal is a quick "yes, go ahead" or a correction, not a lengthy back-and-forth.
|
||||
|
||||
## Build Workflow (creating a new dashboard, AFTER confirmation)
|
||||
|
||||
Once the user has confirmed the plan, your job is to deliver the dashboard in that turn.
|
||||
|
||||
1. Resolve metadata in as few calls as possible: call get_object_metadata and get_field_metadata for the relevant object(s) once, and batch field lookups. Do NOT re-fetch metadata you already have in context.
|
||||
2. Build the full widget configuration using the rules below.
|
||||
3. **Emit \`create_complete_dashboard\` with ALL widgets in a single call.** Prefer one-shot \`create_complete_dashboard\` over building the dashboard incrementally with multiple \`add_dashboard_widget\` calls — fewer round-trips means lower cost and fewer points to stall.
|
||||
4. After the tool returns success, confirm to the user what was built (and restate any assumptions you made).
|
||||
|
||||
### Completion guard (critical, applies once confirmed)
|
||||
|
||||
- After the user has confirmed, you MUST call the appropriate dashboard tool for the requested change in that turn (e.g. \`create_complete_dashboard\`, \`add_dashboard_widget\`, \`update_dashboard_widget\`, or \`delete_dashboard_widget\`). **Never end your turn on a "now let me…" / "I'll build this…" preamble without actually calling the tool.** A preamble with no following tool call (after confirmation) is a failure.
|
||||
- Do NOT yield or hand back to the user until at least one dashboard tool has returned success — unless you are still waiting on confirmation or genuinely blocked on something only the user can answer.
|
||||
|
||||
### Default-and-proceed (resolve defaults in the plan, do not stall)
|
||||
|
||||
- If the request references a field or concept that does not exist in the workspace (e.g. "Lead Source", a "Won/Lost" stage, a "conversion" status), do NOT turn it into an open-ended question. Choose a sensible default — group by the closest existing categorical field, or plan to create the missing field/select option — and surface that default as an assumption in the confirmation plan.
|
||||
- Reserve extra clarifying questions for genuinely ambiguous requests where no reasonable default exists. Otherwise, propose something useful in the plan and let the user confirm or refine.
|
||||
|
||||
## Modifying an existing dashboard
|
||||
|
||||
- Call get_dashboard first to read the current layout, then present the intended changes and get confirmation (see the confirmation gate above) before calling add_dashboard_widget / update_dashboard_widget / delete_dashboard_widget. Use activeTabId from context if available.
|
||||
- Only call get_dashboard when modifying — never before creating a brand-new dashboard.
|
||||
|
||||
## Field Resolution Rules
|
||||
|
||||
@@ -483,13 +507,15 @@ You help users create and manage dashboards with widgets.
|
||||
- IMPORTANT: Put the actual text content in configuration.body.markdown, NOT in the widget title
|
||||
- Widget title should be a short label (e.g. "Notes", "Summary"), body.markdown holds the real content
|
||||
- RECORD_TABLE: configurationType "RECORD_TABLE" — displays a filterable, sortable record list
|
||||
- **MANDATORY 3-step pre-sequence before creating the widget**:
|
||||
1. call create_view (type TABLE, name e.g. "Repairs Dashboard Table") → get the new viewId
|
||||
2. call create_many_view_fields on the new viewId — add 4–6 of the most relevant fields (label identifier + key SELECT/DATE/CURRENCY fields). Use positions 0, 1, 2… and isVisible: true.
|
||||
3. call create_many_view_filters and/or create_view_sort on the new viewId to focus the table (e.g. filter out DONE/CANCELLED records, sort by createdAt DESC or a date field ASC)
|
||||
- **MANDATORY: create the dedicated view in ONE call before creating the widget**:
|
||||
- Call \`upsert_complete_view\` once with the view plus its fields, filters, and sorts. Do NOT use the separate create_view / create_many_view_fields / create_many_view_filters / create_view_sort calls — that is several round-trips where one suffices.
|
||||
- Reference fields by NAME (fieldName) — you generally do not need field UUIDs. Pass fieldMetadataId only if you already have it.
|
||||
- Include 4–6 of the most relevant fields (label identifier + key SELECT/DATE/CURRENCY fields). Order in the array IS the column order.
|
||||
- Add filters/sorts to focus the table (e.g. filter out DONE/CANCELLED records, sort by a date field).
|
||||
- Never reuse a record index view — widget views and record index views must be separate
|
||||
- Leave the view's visibility as WORKSPACE (the default) — never set UNLISTED on a widget-backing view, or the widget will render a blank table
|
||||
- Set objectMetadataId on the widget (top-level, required)
|
||||
- Set configuration.viewId to the UUID of the dedicated view (required)
|
||||
- Set configuration.viewId to the UUID returned by upsert_complete_view (required)
|
||||
- columnSpan 12 (full width) or 6 (half width), rowSpan 6–10
|
||||
|
||||
Example (STANDALONE_RICH_TEXT):
|
||||
@@ -498,12 +524,16 @@ Example (STANDALONE_RICH_TEXT):
|
||||
"body": { "markdown": "## Quarterly Summary\\n\\nKey metrics:\\n- Revenue up 15%\\n- 42 new deals closed\\n\\n**Next steps**: Focus on enterprise pipeline." }
|
||||
}
|
||||
|
||||
Example (RECORD_TABLE — always run the 3-step pre-sequence first):
|
||||
Step 1 — create_view: { "name": "Active Repairs", "objectNameSingular": "repair", "type": "TABLE" } → { "id": "<view-uuid>" }
|
||||
Step 2 — create_many_view_fields: { "viewFields": [{ "viewId": "<view-uuid>", "fieldMetadataId": "<status-field-uuid>", "position": 1, "isVisible": true }, { "viewId": "<view-uuid>", "fieldMetadataId": "<amount-field-uuid>", "position": 2, "isVisible": true }] }
|
||||
Step 3 — create_many_view_filters: { "filters": [{ "viewId": "<view-uuid>", "fieldMetadataId": "<status-field-uuid>", "operand": "IS_NOT", "value": "DONE" }] }
|
||||
Step 3b — create_view_sort: { "viewId": "<view-uuid>", "fieldMetadataId": "<createdAt-field-uuid>", "direction": "DESC" }
|
||||
Step 4 — add_dashboard_widget: { "type": "RECORD_TABLE", "objectMetadataId": "<repair-object-uuid>", "configuration": { "configurationType": "RECORD_TABLE", "viewId": "<view-uuid>" }, "gridPosition": { "row": 0, "column": 0, "rowSpan": 8, "columnSpan": 12 } }
|
||||
Example (RECORD_TABLE — one view call, then the widget):
|
||||
Step 1 — upsert_complete_view: {
|
||||
"name": "Active Repairs",
|
||||
"objectNameSingular": "repair",
|
||||
"type": "TABLE",
|
||||
"fields": [{ "fieldName": "name" }, { "fieldName": "status" }, { "fieldName": "amount" }],
|
||||
"filters": [{ "fieldName": "status", "operand": "IS_NOT", "value": ["DONE"] }],
|
||||
"sorts": [{ "fieldName": "createdAt", "direction": "DESC" }]
|
||||
} → { "id": "<view-uuid>" }
|
||||
Step 2 — add_dashboard_widget: { "type": "RECORD_TABLE", "objectMetadataId": "<repair-object-uuid>", "configuration": { "configurationType": "RECORD_TABLE", "viewId": "<view-uuid>" }, "gridPosition": { "row": 0, "column": 0, "rowSpan": 8, "columnSpan": 12 } }
|
||||
|
||||
## Tabs
|
||||
|
||||
@@ -528,10 +558,6 @@ After creating a tab, use its returned tabId as pageLayoutTabId when calling add
|
||||
- When modifying a chart, confirm whether the user wants to change settings or change chart type
|
||||
- Use RECORD_TABLE widgets to give users direct access to filtered record lists without leaving the dashboard`,
|
||||
isCustom: false,
|
||||
// Dashboard tools are temporarily disabled in AI chat / MCP because the
|
||||
// generated dashboards are not reliable yet. Keeping the skill defined
|
||||
// (inactive) so it can be re-enabled once the tooling is trustworthy.
|
||||
isActive: false,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1268,16 +1294,27 @@ You help users create and configure views to organize how they see their records
|
||||
|
||||
## Tools
|
||||
|
||||
- **upsert_complete_view** - Create OR update a view together with its fields, filters, and sorts in a single call. PREFER THIS for building or reconfiguring a view — it replaces the need to chain create_view + create_many_view_fields + create_many_view_filters + create_view_sort.
|
||||
- get_views - List existing views (filter by object name)
|
||||
- create_view - Create a new view
|
||||
- update_view - Update view name/icon
|
||||
- create_view - Create a new view (low-level; prefer upsert_complete_view)
|
||||
- update_view - Update view name/icon (low-level; prefer upsert_complete_view)
|
||||
- delete_view - Delete a view
|
||||
- create_many_view_fields - Add visible columns to a view
|
||||
- create_many_view_fields - Add visible columns to a view (low-level; prefer upsert_complete_view)
|
||||
- update_many_view_fields - Update column configuration
|
||||
- get_view_fields - List columns in a view
|
||||
- get_object_metadata / get_field_metadata - Discover objects and their fields
|
||||
- navigate_app - Navigate to a view after creation
|
||||
|
||||
## upsert_complete_view (preferred)
|
||||
|
||||
One call builds or reconfigures an entire view:
|
||||
- Omit \`id\` to CREATE (requires \`objectNameSingular\`); provide \`id\` to UPDATE an existing view.
|
||||
- Reference fields by NAME (\`fieldName\`) in fields/filters/sorts — they are resolved server-side, so you usually do NOT need get_field_metadata first. You may pass \`fieldMetadataId\` instead when you already have the UUID.
|
||||
- \`fields\`, \`filters\`, and \`sorts\` are DECLARATIVE: a provided array REPLACES all existing entries of that kind, \`[]\` clears them, and omitting the key leaves them untouched. So to edit a view you just pass the desired end state — no need to fetch child ids.
|
||||
- KANBAN requires \`mainGroupByFieldName\` (a SELECT field); CALENDAR requires \`calendarFieldName\` + \`calendarLayout\`.
|
||||
|
||||
Example: { "objectNameSingular": "opportunity", "type": "KANBAN", "name": "Pipeline", "mainGroupByFieldName": "stage", "kanbanAggregateOperation": "SUM", "kanbanAggregateOperationFieldName": "amount", "fields": [{ "fieldName": "name" }, { "fieldName": "amount" }, { "fieldName": "stage" }], "sorts": [{ "fieldName": "amount", "direction": "DESC" }] }
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Identify the target object**: If the user didn't specify which object, ask them. Present available objects and explain what each holds:
|
||||
@@ -1293,14 +1330,12 @@ You help users create and configure views to organize how they see their records
|
||||
- KANBAN: Ideal when objects have a SELECT field representing stages/statuses (e.g., Opportunity → stage, Task → status)
|
||||
- CALENDAR: Ideal when objects have DATE/DATE_TIME fields (e.g., Opportunity → closeDate, Task → dueAt)
|
||||
|
||||
3. **Create the view**: Use create_view with the right parameters.
|
||||
- For KANBAN: The mainGroupByFieldName is required — ask user which SELECT field to group by, or suggest the most natural one.
|
||||
- For CALENDAR: You must provide both \`calendarFieldName\` (a DATE/DATE_TIME field name) and \`calendarLayout\` ("DAY", "WEEK", or "MONTH") when calling create_view.
|
||||
- For TABLE: No special configuration needed.
|
||||
3. **Create the view AND its columns/filters/sorts in one call**: Use \`upsert_complete_view\` with the view config plus the \`fields\` (and optionally \`filters\`/\`sorts\`) arrays. Reference fields by name.
|
||||
- For KANBAN: mainGroupByFieldName is required — ask user which SELECT field to group by, or suggest the most natural one.
|
||||
- For CALENDAR: provide both \`calendarFieldName\` (a DATE/DATE_TIME field name) and \`calendarLayout\` ("DAY", "WEEK", or "MONTH").
|
||||
- For TABLE: No special configuration needed beyond the fields list.
|
||||
|
||||
4. **Configure view fields**: Use create_many_view_fields to add relevant columns. Choose fields that make sense for the view's purpose. Use decimal positions between 0 and 1 to place them after the label identifier field.
|
||||
|
||||
5. **Navigate**: Use navigate_app to show the user their new view.
|
||||
4. **Navigate**: Use navigate_app to show the user their new view.
|
||||
|
||||
## KANBAN Best Practices
|
||||
|
||||
@@ -1349,8 +1384,9 @@ You help users add filters and sorts to their views so they see the most relevan
|
||||
- get_views - List existing views to find the one to modify
|
||||
- get_view_query_parameters - Check existing filters and sorts on a view
|
||||
- get_field_metadata - Discover fields and their types to build valid filters
|
||||
- create_view_filter / create_many_view_filters - Add filters to a view
|
||||
- create_view_sort / create_many_view_sorts - Add sorts to a view
|
||||
- **upsert_complete_view** - Replace ALL of a view's filters and/or sorts in one call (pass \`id\` + the desired \`filters\`/\`sorts\` arrays, referencing fields by name). Prefer this when setting the full filter/sort set at once.
|
||||
- create_view_filter / create_many_view_filters - Add individual filters to a view (use for surgical single-filter edits)
|
||||
- create_view_sort / create_many_view_sorts - Add individual sorts to a view (use for surgical single-sort edits)
|
||||
- navigate_app - Navigate to the view to show results
|
||||
|
||||
## Filter Operators by Field Type
|
||||
|
||||
+85
@@ -14,7 +14,11 @@ import {
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { getInvalidSelectFilterOptionValues } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util';
|
||||
import { ViewFilterExceptionCode } from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception';
|
||||
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
|
||||
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
|
||||
import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type';
|
||||
@@ -110,6 +114,27 @@ export class FlatViewFilterValidatorService {
|
||||
if (isDefined(incompatibleOperandError)) {
|
||||
validationResult.errors.push(incompatibleOperandError);
|
||||
}
|
||||
|
||||
if (
|
||||
isFieldMetadataEntityOfType(
|
||||
referencedFieldMetadata,
|
||||
FieldMetadataType.SELECT,
|
||||
) ||
|
||||
isFieldMetadataEntityOfType(
|
||||
referencedFieldMetadata,
|
||||
FieldMetadataType.MULTI_SELECT,
|
||||
)
|
||||
) {
|
||||
const invalidSelectOptionError = this.getInvalidSelectOptionError({
|
||||
referencedFieldMetadata,
|
||||
operand: flatViewFilterToValidate.operand,
|
||||
value: flatViewFilterToValidate.value,
|
||||
});
|
||||
|
||||
if (isDefined(invalidSelectOptionError)) {
|
||||
validationResult.errors.push(invalidSelectOptionError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -247,6 +272,30 @@ export class FlatViewFilterValidatorService {
|
||||
if (isDefined(incompatibleOperandError)) {
|
||||
validationResult.errors.push(incompatibleOperandError);
|
||||
}
|
||||
|
||||
if (
|
||||
('value' in flatEntityUpdate ||
|
||||
'fieldMetadataUniversalIdentifier' in flatEntityUpdate ||
|
||||
'operand' in flatEntityUpdate) &&
|
||||
(isFieldMetadataEntityOfType(
|
||||
referencedFieldMetadata,
|
||||
FieldMetadataType.SELECT,
|
||||
) ||
|
||||
isFieldMetadataEntityOfType(
|
||||
referencedFieldMetadata,
|
||||
FieldMetadataType.MULTI_SELECT,
|
||||
))
|
||||
) {
|
||||
const invalidSelectOptionError = this.getInvalidSelectOptionError({
|
||||
referencedFieldMetadata,
|
||||
operand: updatedFlatViewFilter.operand,
|
||||
value: updatedFlatViewFilter.value,
|
||||
});
|
||||
|
||||
if (isDefined(invalidSelectOptionError)) {
|
||||
validationResult.errors.push(invalidSelectOptionError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDefined(updatedFlatViewFilter.viewFilterGroupUniversalIdentifier)) {
|
||||
@@ -268,6 +317,42 @@ export class FlatViewFilterValidatorService {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
private getInvalidSelectOptionError({
|
||||
referencedFieldMetadata,
|
||||
operand,
|
||||
value,
|
||||
}: {
|
||||
referencedFieldMetadata: Pick<
|
||||
FlatFieldMetadata<
|
||||
FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT
|
||||
>,
|
||||
'type' | 'options' | 'label'
|
||||
>;
|
||||
operand: ViewFilterOperand;
|
||||
value: ViewFilterValue;
|
||||
}) {
|
||||
const invalidValues = getInvalidSelectFilterOptionValues({
|
||||
fieldMetadata: referencedFieldMetadata,
|
||||
operand,
|
||||
value,
|
||||
});
|
||||
|
||||
if (invalidValues.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const invalidValuesText = invalidValues.join(', ');
|
||||
const allowedValuesText = referencedFieldMetadata.options
|
||||
?.map((option) => option.value)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
code: ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA,
|
||||
message: t`Filter on "${referencedFieldMetadata.label}" uses option(s) ${invalidValuesText} that do not exist. Allowed values: ${allowedValuesText}.`,
|
||||
userFriendlyMessage: msg`Filter uses a select option that does not exist`,
|
||||
};
|
||||
}
|
||||
|
||||
private getIncompatibleOperandError({
|
||||
operand,
|
||||
fieldType,
|
||||
|
||||
+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,
|
||||
};
|
||||
};
|
||||
-26
@@ -64,19 +64,6 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT shou
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should throw error if view filter value is not a stringified JSON array 1`] = `
|
||||
[
|
||||
{
|
||||
"extensions": {
|
||||
"code": "INTERNAL_SERVER_ERROR",
|
||||
"exceptionEventId": "mocked-exception-id",
|
||||
"userFriendlyMessage": "An error occurred.",
|
||||
},
|
||||
"message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should update related multi selected options view filter 1`] = `
|
||||
{
|
||||
"operand": "CONTAINS",
|
||||
@@ -174,19 +161,6 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should han
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should throw error if view filter value is not a stringified JSON array 1`] = `
|
||||
[
|
||||
{
|
||||
"extensions": {
|
||||
"code": "INTERNAL_SERVER_ERROR",
|
||||
"exceptionEventId": "mocked-exception-id",
|
||||
"userFriendlyMessage": "An error occurred.",
|
||||
},
|
||||
"message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`update-one-field-metadata-view-filters-side-effect-v2 SELECT should update related multi selected options view filter 1`] = `
|
||||
{
|
||||
"operand": "IS",
|
||||
|
||||
+6
-29
@@ -309,8 +309,6 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Note these test exists only because we do not validate the view filter value on creation/update
|
||||
// Should be removed after https://github.com/twentyhq/core-team-issues/issues/1009 completion
|
||||
const failingTestCases: EachTestingContext<{
|
||||
createViewFilterValue: unknown;
|
||||
}>[] = [
|
||||
@@ -334,45 +332,24 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => {
|
||||
type: fieldType,
|
||||
});
|
||||
|
||||
const viewFilterId = '20202020-e3b5-4fa7-85aa-9b1950fc7bf5';
|
||||
|
||||
await createOneViewFilter({
|
||||
const { errors } = await createOneViewFilter({
|
||||
input: {
|
||||
id: viewFilterId,
|
||||
id: '20202020-e3b5-4fa7-85aa-9b1950fc7bf5',
|
||||
viewId: createdView.id,
|
||||
fieldMetadataId: createOneField.id,
|
||||
operand: operandForFieldType,
|
||||
value: createViewFilterValue as unknown as ViewFilterValue,
|
||||
},
|
||||
expectToFail: false,
|
||||
expectToFail: true,
|
||||
gqlFields: `
|
||||
id
|
||||
`,
|
||||
});
|
||||
|
||||
const optionsWithIds = createOneField.options;
|
||||
|
||||
if (!isDefined(optionsWithIds)) {
|
||||
throw new Error('optionsWithIds is not defined');
|
||||
}
|
||||
const updatePayload = {
|
||||
options: optionsWithIds.map((option) => fakeOptionUpdate(option)),
|
||||
};
|
||||
const { errors, data } = await updateOneFieldMetadata({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
idToUpdate: createOneField.id,
|
||||
updatePayload,
|
||||
},
|
||||
gqlFields: `
|
||||
id
|
||||
options
|
||||
`,
|
||||
});
|
||||
|
||||
expect(data).toBeNull();
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors).toMatchSnapshot();
|
||||
expect(errors![0].extensions.code).toBe(
|
||||
'METADATA_VALIDATION_FAILED',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user