diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index df6d8f2b915..d38c62963e7 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3766,8 +3766,13 @@ input UpsertFieldsWidgetGroupInput { } input UpsertFieldsWidgetFieldInput { - """The id of the view field""" - viewFieldId: UUID! + """The id of the view field. Required if fieldMetadataId is not provided.""" + viewFieldId: UUID + + """ + The id of the field metadata. Used to create a new view field when viewFieldId is not provided. + """ + fieldMetadataId: UUID isVisible: Boolean! position: Float! } diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 5f7795deac4..3939afc7604 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -6258,8 +6258,10 @@ fields?: (UpsertFieldsWidgetFieldInput[] | null)} export interface UpsertFieldsWidgetGroupInput {id: Scalars['UUID'],name: Scalars['String'],position: Scalars['Float'],isVisible: Scalars['Boolean'],fields: UpsertFieldsWidgetFieldInput[]} export interface UpsertFieldsWidgetFieldInput { -/** The id of the view field */ -viewFieldId: Scalars['UUID'],isVisible: Scalars['Boolean'],position: Scalars['Float']} +/** The id of the view field. Required if fieldMetadataId is not provided. */ +viewFieldId?: (Scalars['UUID'] | null), +/** The id of the field metadata. Used to create a new view field when viewFieldId is not provided. */ +fieldMetadataId?: (Scalars['UUID'] | null),isVisible: Scalars['Boolean'],position: Scalars['Float']} export interface CreateApiKeyInput {name: Scalars['String'],expiresAt: Scalars['String'],revokedAt?: (Scalars['String'] | null),roleId: Scalars['UUID']} diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index b444994fe53..fd95e48b725 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -9556,6 +9556,9 @@ export default { "viewFieldId": [ 3 ], + "fieldMetadataId": [ + 3 + ], "isVisible": [ 6 ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 7a837a1758a..76d63758ae3 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5514,10 +5514,12 @@ export type UpsertFieldPermissionsInput = { }; export type UpsertFieldsWidgetFieldInput = { + /** The id of the field metadata. Used to create a new view field when viewFieldId is not provided. */ + fieldMetadataId?: InputMaybe; isVisible: Scalars['Boolean']; position: Scalars['Float']; - /** The id of the view field */ - viewFieldId: Scalars['UUID']; + /** The id of the view field. Required if fieldMetadataId is not provided. */ + viewFieldId?: InputMaybe; }; export type UpsertFieldsWidgetGroupInput = { diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts index c3d54f1eadd..6de3531f8b4 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts @@ -73,19 +73,15 @@ export const useSaveFieldsWidgetGroups = () => { name: group.name, position: group.position, isVisible: group.isVisible, - fields: group.fields.flatMap((field) => { - if (!isDefined(field.viewFieldId)) { - return []; - } - - return [ - { - viewFieldId: field.viewFieldId, - isVisible: field.isVisible, - position: field.position, - }, - ]; - }), + fields: group.fields.map((field) => ({ + ...(isDefined(field.viewFieldId) + ? { viewFieldId: field.viewFieldId } + : { + fieldMetadataId: field.fieldMetadataItem.id, + }), + isVisible: field.isVisible, + position: field.position, + })), })), }, }, @@ -97,19 +93,15 @@ export const useSaveFieldsWidgetGroups = () => { variables: { input: { widgetId, - fields: ungroupedFields.flatMap((field) => { - if (!isDefined(field.viewFieldId)) { - return []; - } - - return [ - { - viewFieldId: field.viewFieldId, - isVisible: field.isVisible, - position: field.position, - }, - ]; - }), + fields: ungroupedFields.map((field) => ({ + ...(isDefined(field.viewFieldId) + ? { viewFieldId: field.viewFieldId } + : { + fieldMetadataId: field.fieldMetadataItem.id, + }), + isVisible: field.isVisible, + position: field.position, + })), }, }, }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorGroupsData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorGroupsData.ts index 6ac5a8d0ffe..91e918532c4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorGroupsData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorGroupsData.ts @@ -1,3 +1,4 @@ +import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { type FieldsWidgetEditorMode } from '@/page-layout/widgets/fields/types/FieldsWidgetEditorMode'; import { @@ -6,7 +7,11 @@ import { } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; import { useViewById } from '@/views/hooks/useViewById'; import { useMemo } from 'react'; -import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; +import { + isDefined, + isFieldMetadataEligibleForFieldsWidget, + isNonEmptyArray, +} from 'twenty-shared/utils'; type UseFieldsWidgetEditorGroupsDataParams = { viewId: string | null; @@ -28,6 +33,10 @@ export const useFieldsWidgetEditorGroupsData = ({ const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); + const { labelIdentifierFieldMetadataItem } = + useLabelIdentifierFieldMetadataItem({ + objectNameSingular, + }); const result = useMemo< Pick< @@ -39,6 +48,45 @@ export const useFieldsWidgetEditorGroupsData = ({ return { groups: [], ungroupedFields: [], editorMode: 'ungrouped' }; } + const eligibleFieldMetadataIds = new Set( + objectMetadataItem.fields + .filter((field) => + isFieldMetadataEligibleForFieldsWidget({ + fieldName: field.name, + fieldType: field.type, + isLabelIdentifierField: + field.id === labelIdentifierFieldMetadataItem?.id, + }), + ) + .map((field) => field.id), + ); + + const buildMissingFields = ({ + existingFieldMetadataIds, + startGlobalIndex, + startPosition, + }: { + existingFieldMetadataIds: Set; + startGlobalIndex: number; + startPosition: number; + }): FieldsWidgetGroupField[] => { + let globalIndex = startGlobalIndex; + let position = startPosition; + + return objectMetadataItem.fields + .filter( + (field) => + !existingFieldMetadataIds.has(field.id) && + eligibleFieldMetadataIds.has(field.id), + ) + .map((field) => ({ + fieldMetadataItem: field, + position: position++, + isVisible: false, + globalIndex: globalIndex++, + })); + }; + if (isDefined(view) && isNonEmptyArray(view.viewFieldGroups)) { const viewFieldGroups = view.viewFieldGroups; @@ -47,13 +95,14 @@ export const useFieldsWidgetEditorGroupsData = ({ ); let globalIndex = 0; + const existingFieldMetadataIds = new Set(); const groups = sortedGroups.map((group) => { const groupFields = [...(group.viewFields ?? [])].sort( (a, b) => a.position - b.position, ); - const fields = groupFields + const fields: FieldsWidgetGroupField[] = groupFields .map((viewField) => { const fieldMetadataItem = objectMetadataItem.fields.find( (f) => f.id === viewField.fieldMetadataId, @@ -63,6 +112,8 @@ export const useFieldsWidgetEditorGroupsData = ({ return null; } + existingFieldMetadataIds.add(viewField.fieldMetadataId); + return { fieldMetadataItem, position: viewField.position, @@ -82,11 +133,28 @@ export const useFieldsWidgetEditorGroupsData = ({ }; }); + const lastGroup = groups[groups.length - 1]; + const lastFieldPosition = + lastGroup.fields.length > 0 + ? Math.max(...lastGroup.fields.map((f) => f.position)) + 1 + : 0; + + const missingFields = buildMissingFields({ + existingFieldMetadataIds, + startGlobalIndex: globalIndex, + startPosition: lastFieldPosition, + }); + + if (missingFields.length > 0) { + lastGroup.fields = [...lastGroup.fields, ...missingFields]; + } + return { groups, ungroupedFields: [], editorMode: 'grouped' }; } if (isDefined(view) && view.viewFields.length > 0) { let globalIndex = 0; + const existingFieldMetadataIds = new Set(); const fields = [...view.viewFields] .sort((a, b) => a.position - b.position) @@ -99,6 +167,8 @@ export const useFieldsWidgetEditorGroupsData = ({ return null; } + existingFieldMetadataIds.add(viewField.fieldMetadataId); + return { fieldMetadataItem, position: viewField.position, @@ -109,13 +179,28 @@ export const useFieldsWidgetEditorGroupsData = ({ }) .filter(isDefined); - if (fields.length > 0) { - return { groups: [], ungroupedFields: fields, editorMode: 'ungrouped' }; + const lastFieldPosition = + fields.length > 0 ? Math.max(...fields.map((f) => f.position)) + 1 : 0; + + const missingFields = buildMissingFields({ + existingFieldMetadataIds, + startGlobalIndex: globalIndex, + startPosition: lastFieldPosition, + }); + + const allFields = [...fields, ...missingFields]; + + if (allFields.length > 0) { + return { + groups: [], + ungroupedFields: allFields, + editorMode: 'ungrouped', + }; } } return { groups: [], ungroupedFields: [], editorMode: 'ungrouped' }; - }, [objectMetadataItem, view]); + }, [objectMetadataItem, view, labelIdentifierFieldMetadataItem]); return { ...result, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts index 4c258f68c0f..10616fb2c04 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts @@ -1,15 +1,30 @@ import { Field, InputType } from '@nestjs/graphql'; -import { IsBoolean, IsNotEmpty, IsNumber, IsUUID } from 'class-validator'; +import { IsBoolean, IsNumber, IsOptional, IsUUID } from 'class-validator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { AtLeastOneOf } from 'src/engine/metadata-modules/view-field-group/dtos/validators/at-least-one-of.validator'; @InputType() +@AtLeastOneOf(['viewFieldId', 'fieldMetadataId']) export class UpsertFieldsWidgetFieldInput { + @IsOptional() @IsUUID() - @IsNotEmpty() - @Field(() => UUIDScalarType, { description: 'The id of the view field' }) - viewFieldId: string; + @Field(() => UUIDScalarType, { + nullable: true, + description: + 'The id of the view field. Required if fieldMetadataId is not provided.', + }) + viewFieldId?: string; + + @IsOptional() + @IsUUID() + @Field(() => UUIDScalarType, { + nullable: true, + description: + 'The id of the field metadata. Used to create a new view field when viewFieldId is not provided.', + }) + fieldMetadataId?: string; @IsBoolean() @Field() diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/validators/at-least-one-of.validator.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/validators/at-least-one-of.validator.ts new file mode 100644 index 00000000000..47dab7b12d9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/validators/at-least-one-of.validator.ts @@ -0,0 +1,39 @@ +import { + registerDecorator, + type ValidationArguments, + type ValidationOptions, + ValidatorConstraint, + type ValidatorConstraintInterface, +} from 'class-validator'; +import { isDefined } from 'twenty-shared/utils'; + +@ValidatorConstraint({ async: false }) +export class AtLeastOneOfConstraint implements ValidatorConstraintInterface { + validate(_value: unknown, args: ValidationArguments) { + const [properties] = args.constraints as [string[]]; + const object = args.object as Record; + + return properties.some((property) => isDefined(object[property])); + } + + defaultMessage(args: ValidationArguments) { + const [properties] = args.constraints as [string[]]; + + return `At least one of the following properties must be provided: ${properties.join(', ')}`; + } +} + +export const AtLeastOneOf = ( + properties: string[], + validationOptions?: ValidationOptions, +): ClassDecorator => { + return (target) => { + registerDecorator({ + target, + propertyName: properties[0], + options: validationOptions, + constraints: [properties], + validator: AtLeastOneOfConstraint, + }); + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver.ts index 428c72e133c..7fe5669a3d4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver.ts @@ -1,4 +1,4 @@ -import { UseFilters, UseGuards } from '@nestjs/common'; +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Context, @@ -13,6 +13,7 @@ import { isArray } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -148,6 +149,7 @@ export class ViewFieldGroupResolver { @Mutation(() => ViewDTO) @UseGuards(NoPermissionGuard) + @UsePipes(ResolverValidationPipe) async upsertFieldsWidget( @Args('input') input: UpsertFieldsWidgetInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts index 7774841de1b..de5adc3c7ac 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts @@ -2,17 +2,25 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { t } from '@lingui/core/macro'; -import { isDefined } from 'twenty-shared/utils'; +import { + isDefined, + isFieldMetadataEligibleForFieldsWidget, +} from 'twenty-shared/utils'; import { IsNull, Repository } from 'typeorm'; +import { v4 } from 'uuid'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/is-flat-page-layout-widget-configuration-of-type.util'; import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type'; import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type'; +import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant'; import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type'; import { fromViewFieldOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-overrides-to-universal-overrides.util'; import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type'; @@ -66,6 +74,8 @@ export class FieldsWidgetUpsertService { const { flatPageLayoutWidgetMaps, + flatFieldMetadataMaps, + flatObjectMetadataMaps, flatViewFieldGroupMaps, flatViewFieldMaps, flatViewMaps, @@ -75,6 +85,8 @@ export class FieldsWidgetUpsertService { workspaceId, flatMapsKeys: [ 'flatPageLayoutWidgetMaps', + 'flatFieldMetadataMaps', + 'flatObjectMetadataMaps', 'flatViewFieldGroupMaps', 'flatViewFieldMaps', 'flatViewMaps', @@ -109,6 +121,22 @@ export class FieldsWidgetUpsertService { ); } + const flatView = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: viewId, + flatEntityMaps: flatViewMaps, + }); + + const objectMetadata = isDefined(flatView) + ? findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatView.objectMetadataId, + flatEntityMaps: + flatObjectMetadataMaps as FlatEntityMaps, + }) + : undefined; + + const labelIdentifierFieldMetadataId = + objectMetadata?.labelIdentifierFieldMetadataId ?? null; + const existingGroups = Object.values( flatViewFieldGroupMaps.byUniversalIdentifier, ) @@ -135,6 +163,8 @@ export class FieldsWidgetUpsertService { applicationId: workspaceCustomFlatApplication.id, applicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, + labelIdentifierFieldMetadataId, + flatFieldMetadataMaps, flatViewMaps, flatViewFieldGroupMaps, }); @@ -143,9 +173,14 @@ export class FieldsWidgetUpsertService { inputFields: input.fields!, existingGroups, existingViewFields, + viewId, workspaceId, + applicationId: workspaceCustomFlatApplication.id, applicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, + labelIdentifierFieldMetadataId, + flatFieldMetadataMaps, + flatViewMaps, }); } @@ -171,6 +206,8 @@ export class FieldsWidgetUpsertService { workspaceId, applicationId, applicationUniversalIdentifier, + labelIdentifierFieldMetadataId, + flatFieldMetadataMaps, flatViewMaps, flatViewFieldGroupMaps, }: { @@ -181,6 +218,8 @@ export class FieldsWidgetUpsertService { workspaceId: string; applicationId: string; applicationUniversalIdentifier: string; + labelIdentifierFieldMetadataId: string | null; + flatFieldMetadataMaps: FlatEntityMaps; flatViewMaps: FlatViewMaps; flatViewFieldGroupMaps: FlatViewFieldGroupMaps; }): Promise { @@ -362,6 +401,77 @@ export class FieldsWidgetUpsertService { return [updatedField]; }); + const viewFieldsToCreate: FlatViewField[] = []; + + for (const inputGroup of inputGroups) { + for (const inputField of inputGroup.fields) { + if ( + isDefined(inputField.viewFieldId) || + !isDefined(inputField.fieldMetadataId) + ) { + continue; + } + + const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: inputField.fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if ( + !isDefined(fieldMetadata) || + !isFieldMetadataEligibleForFieldsWidget({ + fieldName: fieldMetadata.name, + fieldType: fieldMetadata.type, + isLabelIdentifierField: + fieldMetadata.id === labelIdentifierFieldMetadataId, + }) + ) { + continue; + } + + const { + fieldMetadataUniversalIdentifier, + viewUniversalIdentifier, + viewFieldGroupUniversalIdentifier, + } = resolveEntityRelationUniversalIdentifiers({ + metadataName: 'viewField', + foreignKeyValues: { + fieldMetadataId: inputField.fieldMetadataId, + viewId, + viewFieldGroupId: inputGroup.id, + }, + flatEntityMaps: { + flatFieldMetadataMaps, + flatViewMaps, + flatViewFieldGroupMaps: optimisticFlatViewFieldGroupMaps, + }, + }); + + viewFieldsToCreate.push({ + id: v4(), + workspaceId, + applicationId, + universalIdentifier: v4(), + applicationUniversalIdentifier, + fieldMetadataId: inputField.fieldMetadataId, + fieldMetadataUniversalIdentifier, + viewId, + viewUniversalIdentifier, + viewFieldGroupId: inputGroup.id, + viewFieldGroupUniversalIdentifier, + isVisible: inputField.isVisible, + size: DEFAULT_VIEW_FIELD_SIZE, + position: inputField.position, + aggregateOperation: null, + overrides: null, + universalOverrides: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + }); + } + } + const fieldsWithStaleGroupOverrides = this.buildFieldUpdatesForStaleGroupOverrides({ existingViewFields, @@ -382,7 +492,7 @@ export class FieldsWidgetUpsertService { flatEntityToUpdate: groupsToUpdate, }, viewField: { - flatEntityToCreate: [], + flatEntityToCreate: viewFieldsToCreate, flatEntityToDelete: [], flatEntityToUpdate: [ ...viewFieldsToUpdate, @@ -408,14 +518,24 @@ export class FieldsWidgetUpsertService { inputFields, existingGroups, existingViewFields, + viewId, workspaceId, + applicationId, applicationUniversalIdentifier, + labelIdentifierFieldMetadataId, + flatFieldMetadataMaps, + flatViewMaps, }: { inputFields: UpsertFieldsWidgetFieldInput[]; existingGroups: FlatViewFieldGroup[]; existingViewFields: FlatViewField[]; + viewId: string; workspaceId: string; + applicationId: string; applicationUniversalIdentifier: string; + labelIdentifierFieldMetadataId: string | null; + flatFieldMetadataMaps: FlatEntityMaps; + flatViewMaps: FlatViewMaps; }): Promise { const now = new Date().toISOString(); @@ -494,6 +614,68 @@ export class FieldsWidgetUpsertService { return [updatedField]; }); + const viewFieldsToCreate: FlatViewField[] = inputFields + .filter((inputField) => { + if ( + isDefined(inputField.viewFieldId) || + !isDefined(inputField.fieldMetadataId) + ) { + return false; + } + + const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: inputField.fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); + + return ( + isDefined(fieldMetadata) && + isFieldMetadataEligibleForFieldsWidget({ + fieldName: fieldMetadata.name, + fieldType: fieldMetadata.type, + isLabelIdentifierField: + fieldMetadata.id === labelIdentifierFieldMetadataId, + }) + ); + }) + .map((inputField) => { + const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } = + resolveEntityRelationUniversalIdentifiers({ + metadataName: 'viewField', + foreignKeyValues: { + fieldMetadataId: inputField.fieldMetadataId!, + viewId, + }, + flatEntityMaps: { + flatFieldMetadataMaps, + flatViewMaps, + }, + }); + + return { + id: v4(), + workspaceId, + applicationId, + universalIdentifier: v4(), + applicationUniversalIdentifier, + fieldMetadataId: inputField.fieldMetadataId!, + fieldMetadataUniversalIdentifier, + viewId, + viewUniversalIdentifier, + viewFieldGroupId: null, + viewFieldGroupUniversalIdentifier: null, + isVisible: inputField.isVisible, + size: DEFAULT_VIEW_FIELD_SIZE, + position: inputField.position, + aggregateOperation: null, + overrides: null, + universalOverrides: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + }; + }); + const fieldsWithStaleGroupOverrides = this.buildFieldUpdatesForStaleGroupOverrides({ existingViewFields, @@ -514,7 +696,7 @@ export class FieldsWidgetUpsertService { flatEntityToUpdate: [], }, viewField: { - flatEntityToCreate: [], + flatEntityToCreate: viewFieldsToCreate, flatEntityToDelete: [], flatEntityToUpdate: [ ...viewFieldsToUpdate, diff --git a/packages/twenty-server/src/engine/metadata-modules/view/utils/__tests__/compute-fields-widget-view-fields-and-groups-to-create.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/view/utils/__tests__/compute-fields-widget-view-fields-and-groups-to-create.util.spec.ts index 1d7554ae31f..8b93c59afa3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/utils/__tests__/compute-fields-widget-view-fields-and-groups-to-create.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/utils/__tests__/compute-fields-widget-view-fields-and-groups-to-create.util.spec.ts @@ -217,7 +217,7 @@ describe('computeFieldsWidgetViewFieldsAndGroupsToCreate', () => { expect(result.flatViewFieldsToCreate).toHaveLength(1); }); - it('should exclude id field unless it is the label identifier', () => { + it('should always exclude id field even when it is the label identifier', () => { const idUid = v4(); const fields = [ @@ -244,7 +244,7 @@ describe('computeFieldsWidgetViewFieldsAndGroupsToCreate', () => { expect(resultWithout.flatViewFieldsToCreate).toHaveLength(1); - // With label identifier match: id included + // With id as label identifier: id still excluded (label identifier fields are excluded) const resultWith = computeFieldsWidgetViewFieldsAndGroupsToCreate({ objectFlatFieldMetadatas: fields, viewUniversalIdentifier, @@ -252,7 +252,7 @@ describe('computeFieldsWidgetViewFieldsAndGroupsToCreate', () => { labelIdentifierFieldMetadataUniversalIdentifier: idUid, }); - expect(resultWith.flatViewFieldsToCreate).toHaveLength(2); + expect(resultWith.flatViewFieldsToCreate).toHaveLength(1); }); it('should set correct applicationUniversalIdentifier on all entities', () => { @@ -339,24 +339,15 @@ describe('computeFieldsWidgetViewFieldsAndGroupsToCreate', () => { labelIdentifierFieldMetadataUniversalIdentifier: labelUid, }); - expect(result.flatViewFieldsToCreate).toHaveLength(3); + // Label identifier field is excluded by isFieldMetadataEligibleForFieldsWidget + expect(result.flatViewFieldsToCreate).toHaveLength(2); - // The label identifier field must be at the lowest position + // The label identifier field should not be in the results const labelField = result.flatViewFieldsToCreate.find( (vf) => vf.fieldMetadataUniversalIdentifier === labelUid, ); - expect(labelField).toBeDefined(); - expect(labelField!.position).toBe(0); - - // Other fields should have higher positions - const otherPositions = result.flatViewFieldsToCreate - .filter((vf) => vf.fieldMetadataUniversalIdentifier !== labelUid) - .map((vf) => vf.position); - - otherPositions.forEach((pos) => { - expect(pos).toBeGreaterThan(0); - }); + expect(labelField).toBeUndefined(); }); it('should make RELATION and MORPH_RELATION fields invisible by default', () => { diff --git a/packages/twenty-server/src/engine/metadata-modules/view/utils/__tests__/is-field-metadata-eligible-for-fields-widget.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/view/utils/__tests__/is-field-metadata-eligible-for-fields-widget.util.spec.ts new file mode 100644 index 00000000000..54b0689671d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view/utils/__tests__/is-field-metadata-eligible-for-fields-widget.util.spec.ts @@ -0,0 +1,75 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { isFieldMetadataEligibleForFieldsWidget } from 'twenty-shared/utils'; + +describe('isFieldMetadataEligibleForFieldsWidget', () => { + it('should exclude deletedAt field', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'deletedAt', + fieldType: FieldMetadataType.DATE_TIME, + isLabelIdentifierField: false, + }), + ).toBe(false); + }); + + it('should exclude TS_VECTOR fields', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'searchVector', + fieldType: FieldMetadataType.TS_VECTOR, + isLabelIdentifierField: false, + }), + ).toBe(false); + }); + + it('should exclude POSITION fields', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'position', + fieldType: FieldMetadataType.POSITION, + isLabelIdentifierField: false, + }), + ).toBe(false); + }); + + it('should exclude id field when it is not the label identifier', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'id', + fieldType: FieldMetadataType.UUID, + isLabelIdentifierField: false, + }), + ).toBe(false); + }); + + it('should exclude id field even when it is the label identifier', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'id', + fieldType: FieldMetadataType.UUID, + isLabelIdentifierField: true, + }), + ).toBe(false); + }); + + it('should include a normal field', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'name', + fieldType: FieldMetadataType.TEXT, + isLabelIdentifierField: false, + }), + ).toBe(true); + }); + + it('should include createdAt field', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'createdAt', + fieldType: FieldMetadataType.DATE_TIME, + isLabelIdentifierField: false, + }), + ).toBe(true); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/view/utils/compute-fields-widget-view-fields-and-groups-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/view/utils/compute-fields-widget-view-fields-and-groups-to-create.util.ts index 2c0c3774811..f714b9f5b23 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/utils/compute-fields-widget-view-fields-and-groups-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/utils/compute-fields-widget-view-fields-and-groups-to-create.util.ts @@ -6,6 +6,7 @@ import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-f import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; import { type UniversalFlatViewFieldGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field-group.type'; import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type'; +import { isFieldMetadataEligibleForFieldsWidget } from 'twenty-shared/utils'; export const computeFieldsWidgetViewFieldsAndGroupsToCreate = ({ objectFlatFieldMetadatas, @@ -24,14 +25,14 @@ export const computeFieldsWidgetViewFieldsAndGroupsToCreate = ({ const createdAt = new Date().toISOString(); const applicationUniversalIdentifier = flatApplication.universalIdentifier; - const eligibleFields = objectFlatFieldMetadatas.filter( - (field) => - field.name !== 'deletedAt' && - field.type !== FieldMetadataType.TS_VECTOR && - field.type !== FieldMetadataType.POSITION && - (field.name !== 'id' || + const eligibleFields = objectFlatFieldMetadatas.filter((field) => + isFieldMetadataEligibleForFieldsWidget({ + fieldName: field.name, + fieldType: field.type, + isLabelIdentifierField: field.universalIdentifier === - labelIdentifierFieldMetadataUniversalIdentifier), + labelIdentifierFieldMetadataUniversalIdentifier, + }), ); const standardFields = eligibleFields.filter((field) => !field.isCustom); diff --git a/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataEligibleForFieldsWidget.test.ts b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataEligibleForFieldsWidget.test.ts new file mode 100644 index 00000000000..ed9483b3bed --- /dev/null +++ b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataEligibleForFieldsWidget.test.ts @@ -0,0 +1,94 @@ +import { FieldMetadataType } from '@/types'; +import { isFieldMetadataEligibleForFieldsWidget } from '@/utils/fieldMetadata/isFieldMetadataEligibleForFieldsWidget'; + +describe('isFieldMetadataEligibleForFieldsWidget', () => { + it('should exclude deletedAt field', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'deletedAt', + fieldType: FieldMetadataType.DATE_TIME, + isLabelIdentifierField: false, + }), + ).toBe(false); + }); + + it('should exclude TS_VECTOR fields', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'searchVector', + fieldType: FieldMetadataType.TS_VECTOR, + isLabelIdentifierField: false, + }), + ).toBe(false); + }); + + it('should exclude POSITION fields', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'position', + fieldType: FieldMetadataType.POSITION, + isLabelIdentifierField: false, + }), + ).toBe(false); + }); + + it('should exclude id field when it is not the label identifier', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'id', + fieldType: FieldMetadataType.UUID, + isLabelIdentifierField: false, + }), + ).toBe(false); + }); + + it('should exclude id field even when it is the label identifier', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'id', + fieldType: FieldMetadataType.UUID, + isLabelIdentifierField: true, + }), + ).toBe(false); + }); + + it('should include a normal field', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'name', + fieldType: FieldMetadataType.TEXT, + isLabelIdentifierField: false, + }), + ).toBe(true); + }); + + it('should include createdAt field', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'createdAt', + fieldType: FieldMetadataType.DATE_TIME, + isLabelIdentifierField: false, + }), + ).toBe(true); + }); + + it('should exclude a label identifier field', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'name', + fieldType: FieldMetadataType.TEXT, + isLabelIdentifierField: true, + }), + ).toBe(false); + }); + + it('should exclude deletedAt field even when it is the label identifier', () => { + expect( + isFieldMetadataEligibleForFieldsWidget({ + fieldName: 'deletedAt', + fieldType: FieldMetadataType.DATE_TIME, + isLabelIdentifierField: true, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/utils/fieldMetadata/index.ts b/packages/twenty-shared/src/utils/fieldMetadata/index.ts index 49d1312130d..f9bb5184a09 100644 --- a/packages/twenty-shared/src/utils/fieldMetadata/index.ts +++ b/packages/twenty-shared/src/utils/fieldMetadata/index.ts @@ -1,5 +1,6 @@ export * from './isFieldMetadataArrayKind'; export * from './isFieldMetadataDateKind'; +export * from './isFieldMetadataEligibleForFieldsWidget'; export * from './isFieldMetadataNumericKind'; export * from './isFieldMetadataSelectKind'; export * from './isFieldMetadataTextKind'; diff --git a/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataEligibleForFieldsWidget.ts b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataEligibleForFieldsWidget.ts new file mode 100644 index 00000000000..ec25a21c6aa --- /dev/null +++ b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataEligibleForFieldsWidget.ts @@ -0,0 +1,33 @@ +import { FieldMetadataType } from '@/types'; + +export const isFieldMetadataEligibleForFieldsWidget = ({ + fieldName, + fieldType, + isLabelIdentifierField, +}: { + fieldName: string; + fieldType: FieldMetadataType; + isLabelIdentifierField: boolean; +}): boolean => { + if (fieldName === 'deletedAt') { + return false; + } + + if (fieldType === FieldMetadataType.TS_VECTOR) { + return false; + } + + if (fieldType === FieldMetadataType.POSITION) { + return false; + } + + if (fieldName === 'id' && !isLabelIdentifierField) { + return false; + } + + if (isLabelIdentifierField) { + return false; + } + + return true; +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 957d5712734..9221ba6f229 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -46,6 +46,7 @@ export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObject export { computeMorphRelationFieldName } from './fieldMetadata/compute-morph-relation-field-name'; export { isFieldMetadataArrayKind } from './fieldMetadata/isFieldMetadataArrayKind'; export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind'; +export { isFieldMetadataEligibleForFieldsWidget } from './fieldMetadata/isFieldMetadataEligibleForFieldsWidget'; export { isFieldMetadataNumericKind } from './fieldMetadata/isFieldMetadataNumericKind'; export { isFieldMetadataSelectKind } from './fieldMetadata/isFieldMetadataSelectKind'; export { isFieldMetadataTextKind } from './fieldMetadata/isFieldMetadataTextKind';