From c11e4ece39bbeaf6ba8355b034986a846f6a4f74 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Tue, 31 Mar 2026 21:00:16 +0200 Subject: [PATCH] Fallback to field metadata (#19131) Rely on the field metadata items to always display all object's fields in the fields widget configuration editor. If fields are missing in the returned view fields, we add the missing fields through object metadata. https://github.com/user-attachments/assets/3c4d45e8-05d0-4943-be4b-bcf1e310155c --- .../src/metadata/generated/schema.graphql | 9 +- .../src/metadata/generated/schema.ts | 6 +- .../src/metadata/generated/types.ts | 3 + .../src/generated-metadata/graphql.ts | 6 +- .../hooks/useSaveFieldsWidgetGroups.ts | 44 ++-- .../hooks/useFieldsWidgetEditorGroupsData.ts | 95 ++++++++- .../upsert-fields-widget-field.input.ts | 23 ++- .../validators/at-least-one-of.validator.ts | 39 ++++ .../resolvers/view-field-group.resolver.ts | 4 +- .../services/fields-widget-upsert.service.ts | 188 +++++++++++++++++- ...w-fields-and-groups-to-create.util.spec.ts | 23 +-- ...ta-eligible-for-fields-widget.util.spec.ts | 75 +++++++ ...t-view-fields-and-groups-to-create.util.ts | 15 +- ...eldMetadataEligibleForFieldsWidget.test.ts | 94 +++++++++ .../src/utils/fieldMetadata/index.ts | 1 + .../isFieldMetadataEligibleForFieldsWidget.ts | 33 +++ packages/twenty-shared/src/utils/index.ts | 1 + 17 files changed, 591 insertions(+), 68 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/validators/at-least-one-of.validator.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/view/utils/__tests__/is-field-metadata-eligible-for-fields-widget.util.spec.ts create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataEligibleForFieldsWidget.test.ts create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataEligibleForFieldsWidget.ts 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';