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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user