From 3ed67b825ea63c0430e26d1ece10bc7c479a13ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 20 Jan 2026 21:58:13 +0100 Subject: [PATCH] feat: implement generic many-to-many junction relation support (#16820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview This PR implements **generic many-to-many relation support** through junction tables (also known as associative entities or join tables). This replaces the need for hardcoded taskTarget/noteTarget logic and provides a flexible foundation for modeling complex entity relationships. ## Architecture ### Data Model Many-to-many relationships are implemented using a **junction object pattern**: ``` ┌─────────┐ ┌──────────────────┐ ┌─────────┐ │ Pet │──────>│ PetRocket │<──────│ Rocket │ │ │ 1:N │ (junction) │ N:1 │ │ │ rockets ├───────┤ pet : Pet ├───────┤ │ └─────────┘ │ rocket : Rocket │ └─────────┘ └──────────────────┘ ``` The junction object (PetRocket) has: - A `MANY_TO_ONE` relation to **Pet** (the source) - A `MANY_TO_ONE` relation to **Rocket** (the target) The source object (Pet) has a `ONE_TO_MANY` relation pointing to the junction, with **field settings** that specify which target field to follow. ### Field Settings Schema Junction configuration is stored in `FieldMetadataRelationSettings`: ```typescript { relationType: "ONE_TO_MANY", // Points to the target field on the junction object junctionTargetFieldId?: string; // For regular relations junctionTargetMorphId?: string; // For polymorphic relations } ``` **Two configuration modes:** 1. **`junctionTargetFieldId`** - References a specific `RELATION` field on the junction 2. **`junctionTargetMorphId`** - References a `morphId` group for polymorphic targets (e.g., link to Person OR Company) ### GraphQL Query Generation When a junction relation is detected, the GraphQL fields are generated to fetch the nested target: ```graphql query GetPetWithRockets { pet(id: "...") { rockets { # ONE_TO_MANY to junction id rocket { # Target field on junction id name __typename } } } } ``` For polymorphic junction targets: ```graphql caretakerPerson { id, name } caretakerCompany { id, name } ``` ## Frontend Architecture ### Display Flow 1. **Detection**: `hasJunctionConfig()` checks if field has junction settings 2. **Config Resolution**: `getJunctionConfig()` resolves junction object metadata and target fields 3. **Record Extraction**: `extractTargetRecordsFromJunction()` extracts target records from junction records 4. **Rendering**: Target records displayed as chips (not junction records) ### Edit Flow 1. **Picker Opening**: Initializes the multi-record picker with: - Searchable object types (derived from junction target fields) - Pre-selected items (extracted from existing junction records) 2. **Selection Handling**: Manages create/delete of junction records: - **Select**: Creates new junction record with source + target IDs - **Deselect**: Finds and deletes the junction record - **Optimistic Updates**: Manually updates Recoil store before API call ### Key Trade-offs | Decision | Trade-off | |----------|-----------| | Junction records managed manually | More control over optimistic updates, but requires manual cache management | | Settings stored per-field | Flexible (same junction can power different views), but requires UI to configure | | Polymorphic via morphId groups | Supports N target types, but adds query complexity | | Feature flag gated | Safe rollout, but requires flag management | ## Backend Changes - **Validation**: Junction target field must exist and be a valid `MANY_TO_ONE` relation - **Settings**: Extended `FieldMetadataRelationSettings` type with junction fields - **Dev Seeder**: Added sample junction objects (PetRocket, EmploymentHistory, PetCareAgreement) for testing ## How to Test 1. Enable the `IS_JUNCTION_RELATIONS_ENABLED` feature flag 2. Create objects with junction pattern (Pet → PetRocket → Rocket) 3. Configure the junction target in field settings (advanced mode) 4. Verify: - Display shows target objects (Rockets), not junction records (PetRockets) - Picker allows selecting/deselecting targets - Changes persist correctly https://github.com/user-attachments/assets/d04f057a-228c-4de8-af48-76bb2d72cac1 --------- Co-authored-by: Charles Bochet --- packages/twenty-front/jest.config.mjs | 4 +- .../src/generated-metadata/graphql.ts | 12 +- .../twenty-front/src/generated/graphql.ts | 3 + .../object-metadata/graphql/fragment.ts | 1 + .../hooks/useActiveFieldMetadataItems.ts | 6 +- .../hooks/useAvailableFieldMetadataItems.ts | 4 +- .../useColumnDefinitionsFromObjectMetadata.ts | 5 +- .../types/FieldMetadataItem.ts | 12 +- .../utils/buildIdentifierGqlFields.ts | 30 ++ .../utils/generateActivityTargetGqlFields.ts | 7 +- .../generateDepthRecordGqlFieldsFromFields.ts | 25 +- .../generateJunctionRelationGqlFields.ts | 112 ++++++ .../components/RecordFieldList.tsx | 7 +- .../useFieldListFieldMetadataFromPosition.ts | 4 +- .../hooks/useFieldListFieldMetadataItems.ts | 65 ++-- .../__tests__/isActivityTargetField.test.ts | 37 ++ .../utils/categorizeRelationFields.ts | 97 +++++ .../visibleRecordFieldsComponentSelector.ts | 14 +- .../ui/hooks/useOpenFieldInputEditMode.ts | 46 ++- .../useOpenJunctionRelationFieldInput.ts | 145 ++++++++ .../useUpdateJunctionRelationFromCell.ts | 233 ++++++++++++ .../RelationFromManyFieldDisplay.tsx | 171 ++++++--- .../display/utils/isFieldIdentifierDisplay.ts | 6 +- .../meta-types/hooks/useChipFieldDisplay.ts | 4 +- .../RelationOneToManyFieldInput.tsx | 206 +++++++++-- .../useAddNewRecordAndOpenRightDrawer.ts | 30 +- .../record-field/ui/types/FieldMetadata.ts | 13 +- .../extractTargetRecordsFromJunction.test.ts | 343 ++++++++++++++++++ .../findJunctionRecordByTargetId.test.ts | 134 +++++++ .../utils/__tests__/getJoinColumnName.test.ts | 30 ++ .../utils/__tests__/getJunctionConfig.test.ts | 240 ++++++++++++ ...etTargetObjectMetadataIdsFromField.test.ts | 83 +++++ .../utils/__tests__/hasJunctionConfig.test.ts | 19 + .../hasJunctionTargetFieldId.test.ts | 37 ++ .../__tests__/isJunctionRelation.test.ts | 85 +++++ .../ui/utils/__tests__/isObjectWithId.test.ts | 32 ++ .../extractTargetRecordsFromJunction.ts | 138 +++++++ .../junction/findJunctionRecordByTargetId.ts | 29 ++ .../ui/utils/junction/findTargetFieldInfo.ts | 77 ++++ .../ui/utils/junction/getJoinColumnName.ts | 16 + .../ui/utils/junction/getJunctionConfig.ts | 98 +++++ .../getSearchableObjectMetadataItems.ts | 18 + .../utils/junction/getSourceJoinColumnName.ts | 39 ++ .../getTargetObjectMetadataIdsFromField.ts | 15 + .../ui/utils/junction/hasJunctionConfig.ts | 12 + .../junction/hasJunctionTargetFieldId.ts | 27 ++ .../utils/junction/isJunctionRelationField.ts | 9 + .../ui/utils/junction/isObjectWithId.ts | 8 + .../junction/types/ExtractedTargetRecord.ts | 7 + .../components/RecordInlineCellValue.tsx | 10 +- .../RecordTitleCellFieldDisplay.tsx | 12 +- .../RecordTitleCellUuidFieldDisplay.tsx | 39 ++ .../utils/buildRecordLabelPayload.ts | 32 ++ .../utils/filterAvailableTableColumns.ts | 4 - .../utils/isFieldCellSupported.ts | 8 +- ...SettingsDataModelFieldSettingsFormCard.tsx | 8 + .../SettingsDataModelFieldRelationForm.tsx | 3 +- ...SettingsDataModelFieldRelationFormCard.tsx | 21 +- ...ingsDataModelFieldRelationJunctionForm.tsx | 163 +++++++++ .../SettingsDataModelObjectAboutForm.tsx | 26 ++ .../settingsDataModelObjectAboutFormSchema.ts | 3 +- .../data-model/SettingsObjectFieldEdit.tsx | 2 +- .../actor-from-auth-context.service.ts | 4 - .../constants/public-feature-flag.const.ts | 9 + .../enums/feature-flag-key.enum.ts | 1 + .../field-metadata/dtos/create-field.input.ts | 1 + .../field-metadata/dtos/field-metadata.dto.ts | 11 +- .../field-metadata/dtos/update-field.input.ts | 1 + ...tion-target-settings-from-settings.util.ts | 31 ++ ...eld-metadata-to-field-metadata-dto.util.ts | 2 + ...-relation-flat-field-metadata-pair.util.ts | 9 + .../validate-junction-target-settings.util.ts | 122 +++++++ ...ph-or-relation-flat-field-metadata.util.ts | 8 + ...and-flat-field-metadatas-to-create.util.ts | 12 +- ...e-flat-object-metadata-identifiers.util.ts | 33 +- .../dtos/create-object.input.ts | 5 + .../object-metadata.service.ts | 11 +- ...-field-metadatas-for-custom-object.util.ts | 81 +++-- .../workspace-entity-manager.spec.ts | 1 + .../core/utils/seed-feature-flags.util.ts | 5 + .../employment-history-data-seeds.constant.ts | 53 +++ .../pet-care-agreement-data-seeds.constant.ts | 45 +++ .../data/services/dev-seeder-data.service.ts | 19 + ...nt-custom-relation-field-seeds.constant.ts | 25 ++ ...et-custom-relation-field-seeds.constant.ts | 12 +- ...ent-history-custom-object-seed.constant.ts | 10 + ...e-agreement-custom-object-seed.constant.ts | 10 + .../services/dev-seeder-metadata.service.ts | 316 +++++++++++++--- .../types/object-metadata-seed.type.ts | 4 +- ...eate-standard-flat-object-metadata.util.ts | 2 + ...eate-standard-object-flat-metadata.util.ts | 8 +- .../get-ts-vector-column-expression.util.ts | 18 +- .../utils/is-searchable-field.util.ts | 1 + .../flat-object-metadata-validator.service.ts | 2 - .../group-by-resolver.integration-spec.ts | 24 +- ...ested-relation-queries.integration-spec.ts | 38 +- .../search-resolver.integration-spec.ts | 194 ++++++++-- ...e-object-metadata.integration-spec.ts.snap | 71 +++- ...te-one-object-metadata.integration-spec.ts | 38 -- .../src/types/FieldMetadataSettings.ts | 3 + .../src/types/IsEmptyRecord.type.ts | 4 +- .../src/utils/fieldMetadata/index.ts | 1 - 102 files changed, 3895 insertions(+), 476 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields.ts create mode 100644 packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/utils/__tests__/isActivityTargetField.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/utils/categorizeRelationFields.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenJunctionRelationFieldInput.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useUpdateJunctionRelationFromCell.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/extractTargetRecordsFromJunction.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/findJunctionRecordByTargetId.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getJoinColumnName.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getJunctionConfig.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getTargetObjectMetadataIdsFromField.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/hasJunctionConfig.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/hasJunctionTargetFieldId.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isJunctionRelation.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isObjectWithId.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/findJunctionRecordByTargetId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/findTargetFieldInfo.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getJoinColumnName.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getJunctionConfig.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getSearchableObjectMetadataItems.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getSourceJoinColumnName.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getTargetObjectMetadataIdsFromField.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/hasJunctionConfig.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/hasJunctionTargetFieldId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/isJunctionRelationField.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/isObjectWithId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/types/ExtractedTargetRecord.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellUuidFieldDisplay.tsx create mode 100644 packages/twenty-front/src/modules/object-record/utils/buildRecordLabelPayload.ts create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationJunctionForm.tsx create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/extract-junction-target-settings-from-settings.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/employment-history-data-seeds.constant.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/pet-care-agreement-data-seeds.constant.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/pet-care-agreement-custom-relation-field-seeds.constant.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/custom-objects/constants/employment-history-custom-object-seed.constant.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/custom-objects/constants/pet-care-agreement-custom-object-seed.constant.ts diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index 7c810f13a3..911af7b214 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -63,8 +63,8 @@ const jestConfig = { coverageThreshold: { global: { statements: 50, - lines: 49, - functions: 41, + lines: 48.9, + functions: 40.9, }, }, collectCoverageFrom: ['/src/**/*.ts'], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 4c085c5a4c..8dda5b86c8 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -960,6 +960,7 @@ export type CreateObjectInput = { primaryKeyColumnType?: InputMaybe; primaryKeyFieldMetadataSettings?: InputMaybe; shortcut?: InputMaybe; + skipNameField?: InputMaybe; }; export type CreateOneAppTokenInput = { @@ -1447,6 +1448,7 @@ export enum FeatureFlagKey { IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED', IS_IF_ELSE_ENABLED = 'IS_IF_ELSE_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', + IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED', @@ -1472,6 +1474,7 @@ export type Field = { isUIReadOnly?: Maybe; isUnique?: Maybe; label: Scalars['String']; + morphId?: Maybe; morphRelations?: Maybe>; name: Scalars['String']; object?: Maybe; @@ -5685,7 +5688,7 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{ export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean }; -export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array, createdAt: string, updatedAt: string }> | null }> }; +export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array, createdAt: string, updatedAt: string }> | null }> }; export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>; @@ -5697,7 +5700,7 @@ export type FindOneApplicationQueryVariables = Exact<{ }>; -export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array, createdAt: string, updatedAt: string }> | null }> } }; +export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array, createdAt: string, updatedAt: string }> | null }> } }; export type UploadFileMutationVariables = Exact<{ file: Scalars['Upload']; @@ -6007,7 +6010,7 @@ export type DeleteFileMutationVariables = Exact<{ export type DeleteFileMutation = { __typename?: 'Mutation', deleteFile: { __typename?: 'File', id: string, path: string, size: number, createdAt: string } }; -export type ObjectMetadataFieldsFragment = { __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }; +export type ObjectMetadataFieldsFragment = { __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }; export type CreateOneObjectMetadataItemMutationVariables = Exact<{ input: CreateOneObjectInput; @@ -6056,7 +6059,7 @@ export type DeleteOneFieldMetadataItemMutation = { __typename?: 'Mutation', dele export type ObjectMetadataItemsQueryVariables = Exact<{ [key: string]: never; }>; -export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __typename?: 'ObjectConnection', edges: Array<{ __typename?: 'ObjectEdge', node: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } }; +export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __typename?: 'ObjectConnection', edges: Array<{ __typename?: 'ObjectEdge', node: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } }; export type SkipBookOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; @@ -7153,6 +7156,7 @@ export const ObjectMetadataFieldsFragmentDoc = gql` options settings isLabelSyncedWithName + morphId applicationId relation { type diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 02eeaa9c99..60e6e6edfd 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -951,6 +951,7 @@ export type CreateObjectInput = { primaryKeyColumnType?: InputMaybe; primaryKeyFieldMetadataSettings?: InputMaybe; shortcut?: InputMaybe; + skipNameField?: InputMaybe; }; export type CreateOneFieldMetadataInput = { @@ -1424,6 +1425,7 @@ export enum FeatureFlagKey { IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED', IS_IF_ELSE_ENABLED = 'IS_IF_ELSE_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', + IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED', @@ -1449,6 +1451,7 @@ export type Field = { isUIReadOnly?: Maybe; isUnique?: Maybe; label: Scalars['String']; + morphId?: Maybe; morphRelations?: Maybe>; name: Scalars['String']; object?: Maybe; diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/fragment.ts b/packages/twenty-front/src/modules/object-metadata/graphql/fragment.ts index de7547e96b..db07ff586d 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/fragment.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/fragment.ts @@ -59,6 +59,7 @@ export const OBJECT_METADATA_FRAGMENT = gql` options settings isLabelSyncedWithName + morphId applicationId relation { type diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useActiveFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useActiveFieldMetadataItems.ts index 40fde58ebc..b943edb1c3 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useActiveFieldMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useActiveFieldMetadataItems.ts @@ -11,11 +11,13 @@ export const useActiveFieldMetadataItems = ({ () => objectMetadataItem ? objectMetadataItem.readableFields.filter( - ({ isActive, isSystem, name }) => + ({ id, isActive, isSystem, name }) => isActiveFieldMetadataItem({ objectNameSingular: objectMetadataItem.nameSingular, fieldMetadata: { isActive, isSystem, name }, - }), + }) || + // Allow label identifier field even if it's a system field + id === objectMetadataItem.labelIdentifierFieldMetadataId, ) : [], [objectMetadataItem], diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useAvailableFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useAvailableFieldMetadataItems.ts index 596544b317..a8b88949c0 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useAvailableFieldMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useAvailableFieldMetadataItems.ts @@ -1,7 +1,6 @@ import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; import { TABLE_COLUMNS_DENY_LIST } from '@/object-record/constants/TableColumnsDenyList'; import { useMemo } from 'react'; -import { FieldMetadataType } from 'twenty-shared/types'; export const useAvailableFieldMetadataItems = ({ objectMetadataItemId, @@ -18,8 +17,7 @@ export const useAvailableFieldMetadataItems = ({ return ( fieldMetadataItemToFilter.isActive && !fieldMetadataItemToFilter.isSystem && - !TABLE_COLUMNS_DENY_LIST.includes(fieldMetadataItemToFilter.name) && - fieldMetadataItemToFilter.type !== FieldMetadataType.UUID + !TABLE_COLUMNS_DENY_LIST.includes(fieldMetadataItemToFilter.name) ); }), [objectMetadataItem], diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata.ts index 256f165684..684251fc04 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata.ts @@ -12,7 +12,10 @@ export const useColumnDefinitionsFromObjectMetadata = ( objectMetadataItem: ObjectMetadataItem, ) => { const activeFieldMetadataItems = objectMetadataItem.readableFields.filter( - ({ isActive, isSystem }) => isActive && !isSystem, + ({ id, isActive, isSystem }) => + isActive && + // Allow label identifier field (e.g. id for junction tables) even if it's a system field + (!isSystem || id === objectMetadataItem.labelIdentifierFieldMetadataId), ); const filterableFieldMetadataItems = useRecoilValue( diff --git a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts index 52c7fa5a8e..abc16612fb 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts @@ -1,5 +1,8 @@ import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation'; -import { type FieldDateMetadataSettings } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { + type FieldDateMetadataSettings, + type FieldRelationMetadataSettings, +} from '@/object-record/record-field/ui/types/FieldMetadata'; import { type FieldMetadataMultiItemSettings, @@ -21,6 +24,11 @@ export type FieldMetadataItem = Omit< options?: FieldMetadataItemOption[] | null; relation?: FieldMetadataItemRelation | null; morphRelations?: FieldMetadataItemRelation[] | null; - settings?: FieldDateMetadataSettings | FieldMetadataMultiItemSettings | null; + settings?: + | FieldDateMetadataSettings + | FieldMetadataMultiItemSettings + | FieldRelationMetadataSettings + | null; isLabelSyncedWithName?: boolean | null; + morphId?: string | null; }; diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields.ts b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields.ts new file mode 100644 index 0000000000..0df0513cc0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields.ts @@ -0,0 +1,30 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; +import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields'; +import { isDefined } from 'twenty-shared/utils'; + +export const buildIdentifierGqlFields = ( + objectMetadata: Pick< + ObjectMetadataItem, + | 'fields' + | 'labelIdentifierFieldMetadataId' + | 'imageIdentifierFieldMetadataId' + | 'nameSingular' + >, +): RecordGqlFields => { + const labelIdentifierField = + getLabelIdentifierFieldMetadataItem(objectMetadata); + const imageIdentifierField = + getImageIdentifierFieldMetadataItem(objectMetadata); + + return { + id: true, + ...(isDefined(labelIdentifierField) && { + [labelIdentifierField.name]: true, + }), + ...(isDefined(imageIdentifierField) && { + [imageIdentifierField.name]: true, + }), + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateActivityTargetGqlFields.ts b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateActivityTargetGqlFields.ts index 576b937b7f..554e1160b6 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateActivityTargetGqlFields.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateActivityTargetGqlFields.ts @@ -7,7 +7,12 @@ import { isDefined } from 'twenty-shared/utils'; export type GenerateDepthRecordGqlFields = { objectMetadataItems: Pick< ObjectMetadataItem, - 'id' | 'nameSingular' | 'fields' | 'labelIdentifierFieldMetadataId' + | 'id' + | 'nameSingular' + | 'namePlural' + | 'fields' + | 'labelIdentifierFieldMetadataId' + | 'imageIdentifierFieldMetadataId' >[]; activityObjectNameSingular: | CoreObjectNameSingular.Note diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields.ts b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields.ts index ce85bf1342..4b8f5897db 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields.ts @@ -3,17 +3,24 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem'; import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields'; +import { generateJunctionRelationGqlFields } from '@/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields'; +import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField'; import { FieldMetadataType, RelationType } from 'twenty-shared/types'; import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; export type GenerateDepthRecordGqlFieldsFromFields = { objectMetadataItems: Pick< ObjectMetadataItem, - 'id' | 'fields' | 'labelIdentifierFieldMetadataId' | 'nameSingular' + | 'id' + | 'fields' + | 'labelIdentifierFieldMetadataId' + | 'imageIdentifierFieldMetadataId' + | 'nameSingular' + | 'namePlural' >[]; fields: Pick< FieldMetadataItem, - 'name' | 'type' | 'settings' | 'morphRelations' | 'relation' + 'id' | 'name' | 'type' | 'settings' | 'morphRelations' | 'relation' >[]; depth: 0 | 1; shouldOnlyLoadRelationIdentifiers?: boolean; @@ -47,6 +54,20 @@ export const generateDepthRecordGqlFieldsFromFields = ({ ); } + if (isJunctionRelationField(fieldMetadata)) { + const junctionGqlFields = generateJunctionRelationGqlFields({ + fieldMetadataItem: fieldMetadata, + objectMetadataItems, + }); + + if (isDefined(junctionGqlFields) && depth === 1) { + return { + ...recordGqlFields, + [fieldMetadata.name]: junctionGqlFields, + }; + } + } + const labelIdentifierFieldMetadataItem = getLabelIdentifierFieldMetadataItem(targetObjectMetadataItem); diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields.ts b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields.ts new file mode 100644 index 0000000000..fee5a97fa3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields.ts @@ -0,0 +1,112 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields'; +import { buildIdentifierGqlFields } from '@/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields'; +import { + getJunctionConfig, + type JunctionObjectMetadataItem, +} from '@/object-record/record-field/ui/utils/junction/getJunctionConfig'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; + +type JunctionFieldMetadataItem = Pick< + FieldMetadataItem, + 'id' | 'name' | 'type' | 'settings' | 'morphRelations' | 'relation' +>; + +type GenerateJunctionRelationGqlFieldsArgs = { + fieldMetadataItem: JunctionFieldMetadataItem; + objectMetadataItems: JunctionObjectMetadataItem[]; +}; + +const buildRegularTargetFieldGqlFields = ( + targetField: JunctionFieldMetadataItem, + objectMetadataItems: JunctionObjectMetadataItem[], +): RecordGqlFields => { + const targetObjectMetadata = objectMetadataItems.find( + (item) => item.id === targetField.relation?.targetObjectMetadata.id, + ); + + if (!isDefined(targetObjectMetadata)) { + return {}; + } + + return { + [targetField.name]: buildIdentifierGqlFields(targetObjectMetadata), + }; +}; + +const buildMorphTargetFieldGqlFields = ( + targetField: JunctionFieldMetadataItem, + objectMetadataItems: JunctionObjectMetadataItem[], +): RecordGqlFields => { + const morphRelations = targetField.morphRelations; + + if (!Array.isArray(morphRelations) || morphRelations.length === 0) { + return {}; + } + + const result: RecordGqlFields = {}; + + for (const morphRelation of morphRelations) { + const targetObjectMetadata = objectMetadataItems.find( + (item) => item.id === morphRelation.targetObjectMetadata.id, + ); + + if (!isDefined(targetObjectMetadata)) { + continue; + } + + const computedFieldName = computeMorphRelationFieldName({ + fieldName: morphRelation.sourceFieldMetadata.name, + relationType: morphRelation.type, + targetObjectMetadataNameSingular: targetObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: targetObjectMetadata.namePlural, + }); + + result[computedFieldName] = buildIdentifierGqlFields(targetObjectMetadata); + } + + return result; +}; + +const buildTargetFieldGqlFields = ( + targetField: JunctionFieldMetadataItem, + objectMetadataItems: JunctionObjectMetadataItem[], +): RecordGqlFields => { + if (targetField.type === FieldMetadataType.MORPH_RELATION) { + return buildMorphTargetFieldGqlFields(targetField, objectMetadataItems); + } + return buildRegularTargetFieldGqlFields(targetField, objectMetadataItems); +}; + +// Generates GraphQL fields for a junction relation, including the nested target objects +export const generateJunctionRelationGqlFields = ({ + fieldMetadataItem, + objectMetadataItems, +}: GenerateJunctionRelationGqlFieldsArgs): RecordGqlFields | null => { + const junctionConfig = getJunctionConfig({ + settings: fieldMetadataItem.settings, + relationObjectMetadataId: + fieldMetadataItem.relation?.targetObjectMetadata.id ?? '', + objectMetadataItems, + }); + + if (!isDefined(junctionConfig)) { + return null; + } + + const { junctionObjectMetadata, targetFields } = junctionConfig; + + const junctionTargetFields = targetFields.reduce( + (acc, targetField) => ({ + ...acc, + ...buildTargetFieldGqlFields(targetField, objectMetadataItems), + }), + {}, + ); + + return { + ...buildIdentifierGqlFields(junctionObjectMetadata), + ...junctionTargetFields, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx index b22304cd2b..f82cef9754 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx @@ -75,7 +75,7 @@ export const RecordFieldList = ({ const { inlineFieldMetadataItems, - inlineRelationFieldMetadataItems, + legacyActivityTargetFieldMetadataItems, boxedRelationFieldMetadataItems, } = useFieldListFieldMetadataItems({ objectNameSingular, @@ -95,7 +95,7 @@ export const RecordFieldList = ({ ) : ( <> - {inlineRelationFieldMetadataItems?.map( + {legacyActivityTargetFieldMetadataItems?.map( (fieldMetadataItem, index) => ( handleMouseEnter( - index + (inlineRelationFieldMetadataItems?.length ?? 0), + index + + (legacyActivityTargetFieldMetadataItems?.length ?? 0), ), anchorId: `${getRecordFieldInputInstanceId({ recordId: objectRecordId, diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition.ts b/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition.ts index 7b10e934ea..07ae279b97 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition.ts +++ b/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition.ts @@ -21,14 +21,14 @@ export const useFieldListFieldMetadataFromPosition = ({ const { inlineFieldMetadataItems, - inlineRelationFieldMetadataItems, + legacyActivityTargetFieldMetadataItems, boxedRelationFieldMetadataItems, } = useFieldListFieldMetadataItems({ objectNameSingular, }); const fieldMetadataItems = [ - ...inlineRelationFieldMetadataItems, + ...legacyActivityTargetFieldMetadataItems, ...inlineFieldMetadataItems, ...boxedRelationFieldMetadataItems, ]; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts index 1821cef540..7c6bee0009 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts @@ -1,13 +1,13 @@ import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { categorizeRelationFields } from '@/object-record/record-field-list/utils/categorizeRelationFields'; import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import groupBy from 'lodash.groupby'; import { FieldMetadataType } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { FeatureFlagKey } from '~/generated/graphql'; type UseFieldListFieldMetadataItemsProps = { objectNameSingular: string; @@ -35,6 +35,10 @@ export const useFieldListFieldMetadataItems = ({ const { objectMetadataItems } = useObjectMetadataItems(); + const isJunctionRelationsEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_JUNCTION_RELATIONS_ENABLED, + ); + const availableFieldMetadataItems = objectMetadataItem.readableFields .filter( (fieldMetadataItem) => @@ -70,49 +74,22 @@ export const useFieldListFieldMetadataItems = ({ : 'inlineFieldMetadataItems', ); - const inlineRelationFieldMetadataItems = ( - relationFieldMetadataItems ?? [] - ).filter( - (fieldMetadataItem) => - (objectNameSingular === CoreObjectNameSingular.Note && - fieldMetadataItem.name === 'noteTargets') || - (objectNameSingular === CoreObjectNameSingular.Task && - fieldMetadataItem.name === 'taskTargets'), - ); - - const boxedRelationFieldMetadataItems = (relationFieldMetadataItems ?? []) - .filter( - (fieldMetadataItem) => - !( - (objectNameSingular === CoreObjectNameSingular.Note && - fieldMetadataItem.name === 'noteTargets') || - (objectNameSingular === CoreObjectNameSingular.Task && - fieldMetadataItem.name === 'taskTargets') - ), - ) - .filter((fieldMetadataItem) => { - const canReadRelation = - isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id) && - getObjectPermissionsForObject( - objectPermissionsByObjectMetadataId, - fieldMetadataItem.relation?.targetObjectMetadata.id, - ).canReadObjectRecords; - - const canReadMorphRelation = fieldMetadataItem?.morphRelations?.every( - (morphRelation) => - isDefined(morphRelation.targetObjectMetadata.id) && - getObjectPermissionsForObject( - objectPermissionsByObjectMetadataId, - morphRelation.targetObjectMetadata.id, - ).canReadObjectRecords, - ); - - return canReadRelation || canReadMorphRelation; + const { activityTargetFields, inlineRelationFields, boxedRelationFields } = + categorizeRelationFields({ + relationFields: relationFieldMetadataItems ?? [], + objectNameSingular, + objectPermissionsByObjectMetadataId, + isJunctionRelationsEnabled, }); + const allInlineFieldMetadataItems = [ + ...(inlineFieldMetadataItems ?? []), + ...inlineRelationFields, + ].sort((a, b) => a.name.localeCompare(b.name)); + return { - inlineFieldMetadataItems, - inlineRelationFieldMetadataItems, - boxedRelationFieldMetadataItems, + inlineFieldMetadataItems: allInlineFieldMetadataItems, + legacyActivityTargetFieldMetadataItems: activityTargetFields, + boxedRelationFieldMetadataItems: boxedRelationFields, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/utils/__tests__/isActivityTargetField.test.ts b/packages/twenty-front/src/modules/object-record/record-field-list/utils/__tests__/isActivityTargetField.test.ts new file mode 100644 index 0000000000..afd1842a48 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/utils/__tests__/isActivityTargetField.test.ts @@ -0,0 +1,37 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { isActivityTargetField } from '@/object-record/record-field-list/utils/categorizeRelationFields'; + +describe('isActivityTargetField', () => { + it('should return true for noteTargets field on Note object', () => { + expect( + isActivityTargetField('noteTargets', CoreObjectNameSingular.Note), + ).toBe(true); + }); + + it('should return true for taskTargets field on Task object', () => { + expect( + isActivityTargetField('taskTargets', CoreObjectNameSingular.Task), + ).toBe(true); + }); + + it('should return false for noteTargets field on non-Note object', () => { + expect( + isActivityTargetField('noteTargets', CoreObjectNameSingular.Task), + ).toBe(false); + }); + + it('should return false for taskTargets field on non-Task object', () => { + expect( + isActivityTargetField('taskTargets', CoreObjectNameSingular.Note), + ).toBe(false); + }); + + it('should return false for regular field names', () => { + expect( + isActivityTargetField('company', CoreObjectNameSingular.Person), + ).toBe(false); + expect( + isActivityTargetField('people', CoreObjectNameSingular.Company), + ).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/utils/categorizeRelationFields.ts b/packages/twenty-front/src/modules/object-record/record-field-list/utils/categorizeRelationFields.ts new file mode 100644 index 0000000000..f9777e2786 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/utils/categorizeRelationFields.ts @@ -0,0 +1,97 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; +import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField'; +import { type ObjectPermissions } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +type ObjectPermissionsByObjectMetadataId = Record< + string, + ObjectPermissions & { objectMetadataId: string } +>; + +type CategorizeRelationFieldsArgs = { + relationFields: FieldMetadataItem[]; + objectNameSingular: string; + objectPermissionsByObjectMetadataId: ObjectPermissionsByObjectMetadataId; + isJunctionRelationsEnabled: boolean; +}; + +type CategorizedRelationFields = { + activityTargetFields: FieldMetadataItem[]; + inlineRelationFields: FieldMetadataItem[]; + boxedRelationFields: FieldMetadataItem[]; +}; + +export const isActivityTargetField = ( + fieldName: string, + objectNameSingular: string, +): boolean => + (objectNameSingular === CoreObjectNameSingular.Note && + fieldName === 'noteTargets') || + (objectNameSingular === CoreObjectNameSingular.Task && + fieldName === 'taskTargets'); + +const isActivityTargetRelation = ( + fieldMetadataItem: FieldMetadataItem, + objectNameSingular: string, +): boolean => isActivityTargetField(fieldMetadataItem.name, objectNameSingular); + +const canReadRelationTarget = ( + fieldMetadataItem: FieldMetadataItem, + objectPermissionsByObjectMetadataId: ObjectPermissionsByObjectMetadataId, +): boolean => { + const canReadRelation = + isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id) && + getObjectPermissionsForObject( + objectPermissionsByObjectMetadataId, + fieldMetadataItem.relation?.targetObjectMetadata.id, + ).canReadObjectRecords; + + const canReadMorphRelation = fieldMetadataItem?.morphRelations?.every( + (morphRelation) => + isDefined(morphRelation.targetObjectMetadata.id) && + getObjectPermissionsForObject( + objectPermissionsByObjectMetadataId, + morphRelation.targetObjectMetadata.id, + ).canReadObjectRecords, + ); + + return canReadRelation || (canReadMorphRelation ?? false); +}; + +export const categorizeRelationFields = ({ + relationFields, + objectNameSingular, + objectPermissionsByObjectMetadataId, + isJunctionRelationsEnabled, +}: CategorizeRelationFieldsArgs): CategorizedRelationFields => { + const activityTargetFields: FieldMetadataItem[] = []; + const inlineRelationFields: FieldMetadataItem[] = []; + const boxedRelationFields: FieldMetadataItem[] = []; + + for (const field of relationFields) { + // Activity targets are always rendered with ActivityTargetsInlineCell + if (isActivityTargetRelation(field, objectNameSingular)) { + activityTargetFields.push(field); + continue; + } + + // Junction relations (when feature enabled) are rendered inline with other fields + if (isJunctionRelationsEnabled && isJunctionRelationField(field)) { + inlineRelationFields.push(field); + continue; + } + + // Boxed relations need read permission check + if (canReadRelationTarget(field, objectPermissionsByObjectMetadataId)) { + boxedRelationFields.push(field); + } + } + + return { + activityTargetFields, + inlineRelationFields, + boxedRelationFields, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/visibleRecordFieldsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-field/states/visibleRecordFieldsComponentSelector.ts index 66a70db8fc..a8fff75b46 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/states/visibleRecordFieldsComponentSelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/states/visibleRecordFieldsComponentSelector.ts @@ -48,10 +48,16 @@ export const visibleRecordFieldsComponentSelector = createComponentSelector({ return false; } - const isActive = isActiveFieldMetadataItem({ - objectNameSingular: objectMetadataItem.nameSingular, - fieldMetadata: fieldMetadataItem, - }); + const isLabelIdentifier = + fieldMetadataItem.id === + objectMetadataItem.labelIdentifierFieldMetadataId; + + const isActive = + isLabelIdentifier || + isActiveFieldMetadataItem({ + objectNameSingular: objectMetadataItem.nameSingular, + fieldMetadata: fieldMetadataItem, + }); const isReadable = objectMetadataItem.readableFields.some( findById(fieldMetadataItem.id), diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts index ad63908540..0ee8c80be5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts @@ -4,7 +4,9 @@ import { type NoteTarget } from '@/activities/types/NoteTarget'; import { type Task } from '@/activities/types/Task'; import { type TaskTarget } from '@/activities/types/TaskTarget'; import { getActivityTargetObjectRecords } from '@/activities/utils/getActivityTargetObjectRecords'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { useOpenJunctionRelationFieldInput } from '@/object-record/record-field/ui/hooks/useOpenJunctionRelationFieldInput'; import { useOpenMorphRelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationManyToOneFieldInput'; import { useOpenMorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationOneToManyFieldInput'; import { useOpenRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenRelationFromManyFieldInput'; @@ -13,6 +15,7 @@ import { type FieldDefinition } from '@/object-record/record-field/ui/types/Fiel import { type FieldMetadata, type FieldRelationFromManyValue, + type FieldRelationMetadata, type FieldRelationValue, } from '@/object-record/record-field/ui/types/FieldMetadata'; import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; @@ -20,6 +23,7 @@ import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/t import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany'; import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne'; import { isFieldRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldRelationOneToMany'; +import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; @@ -40,6 +44,9 @@ export const useOpenFieldInputEditMode = () => { const { openActivityTargetCellEditMode } = useOpenActivityTargetCellEditMode(); + const { openJunctionRelationFieldInput } = + useOpenJunctionRelationFieldInput(); + const { openMorphRelationManyToOneFieldInput } = useOpenMorphRelationManyToOneFieldInput(); @@ -56,6 +63,40 @@ export const useOpenFieldInputEditMode = () => { recordId: string; prefix?: string; }) => { + const objectMetadataItems = snapshot + .getLoadable(objectMetadataItemsState) + .getValue(); + + const currentWorkspace = snapshot + .getLoadable(currentWorkspaceState) + .getValue(); + + const isJunctionRelationsEnabled = + currentWorkspace?.featureFlags?.find( + (flag) => (flag.key as string) === 'IS_JUNCTION_RELATIONS_ENABLED', + )?.value ?? false; + + const isOneToMany = isFieldRelationOneToMany(fieldDefinition); + const fieldHasJunctionConfig = hasJunctionConfig( + fieldDefinition.metadata.settings, + ); + + if ( + isJunctionRelationsEnabled && + isOneToMany && + fieldHasJunctionConfig + ) { + openJunctionRelationFieldInput({ + fieldDefinition: + fieldDefinition as FieldDefinition, + recordId, + prefix, + }); + return; + } + + // Backward compatibility: hardcoded taskTarget/noteTarget check + // TODO: Remove this once taskTarget/noteTarget are migrated to use junction configuration if ( isFieldRelationOneToMany(fieldDefinition) && ['taskTarget', 'noteTarget'].includes( @@ -75,10 +116,6 @@ export const useOpenFieldInputEditMode = () => { .getLoadable(recordStoreFamilyState(recordId)) .getValue(); - const objectMetadataItems = snapshot - .getLoadable(objectMetadataItemsState) - .getValue(); - const activityTargetObjectRecords = getActivityTargetObjectRecords({ activityRecord: activity as Task | Note, objectMetadataItems, @@ -166,6 +203,7 @@ export const useOpenFieldInputEditMode = () => { }, [ openActivityTargetCellEditMode, + openJunctionRelationFieldInput, openMorphRelationManyToOneFieldInput, openMorphRelationOneToManyFieldInput, openRelationFromManyFieldInput, diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenJunctionRelationFieldInput.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenJunctionRelationFieldInput.ts new file mode 100644 index 0000000000..87717b79fd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenJunctionRelationFieldInput.ts @@ -0,0 +1,145 @@ +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { + type FieldRelationFromManyValue, + type FieldRelationMetadata, + type FieldRelationValue, +} from '@/object-record/record-field/ui/types/FieldMetadata'; +import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction'; +import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig'; +import { getSearchableObjectMetadataItems } from '@/object-record/record-field/ui/utils/junction/getSearchableObjectMetadataItems'; +import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen'; +import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch'; +import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; +import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState'; +import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useOpenJunctionRelationFieldInput = () => { + const { performSearch } = useMultipleRecordPickerPerformSearch(); + const { openMultipleRecordPicker } = useMultipleRecordPickerOpen(); + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + + const openJunctionRelationFieldInput = useRecoilCallback( + ({ set, snapshot }) => + ({ + fieldDefinition, + recordId, + prefix, + }: { + fieldDefinition: FieldDefinition; + recordId: string; + prefix?: string; + }) => { + const objectMetadataItems = snapshot + .getLoadable(objectMetadataItemsState) + .getValue(); + + const sourceObjectMetadataId = objectMetadataItems.find( + (item) => + item.nameSingular === + fieldDefinition.metadata.objectMetadataNameSingular, + )?.id; + + const junctionConfig = getJunctionConfig({ + settings: fieldDefinition.metadata.settings, + relationObjectMetadataId: + fieldDefinition.metadata.relationObjectMetadataId, + sourceObjectMetadataId, + objectMetadataItems, + }); + + if (!isDefined(junctionConfig)) { + return; + } + + const { targetFields } = junctionConfig; + + if (targetFields.length === 0) { + return; + } + + const recordPickerInstanceId = getRecordFieldInputInstanceId({ + recordId, + fieldName: fieldDefinition.metadata.fieldName, + prefix, + }); + + const junctionRecords = snapshot + .getLoadable>( + recordStoreFamilySelector({ + recordId, + fieldName: fieldDefinition.metadata.fieldName, + }), + ) + .getValue(); + + const selectedTargetRecords = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields, + objectMetadataItems, + }); + + const searchableObjectMetadataItems = getSearchableObjectMetadataItems( + targetFields, + objectMetadataItems, + ); + + const pickableMorphItems = selectedTargetRecords.map((record) => ({ + recordId: record.recordId, + objectMetadataId: record.objectMetadataId, + isSelected: true, + isMatchingSearchFilter: true, + })); + + set( + multipleRecordPickerPickableMorphItemsComponentState.atomFamily({ + instanceId: recordPickerInstanceId, + }), + pickableMorphItems, + ); + + set( + multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily( + { instanceId: recordPickerInstanceId }, + ), + searchableObjectMetadataItems, + ); + + set( + multipleRecordPickerSearchFilterComponentState.atomFamily({ + instanceId: recordPickerInstanceId, + }), + '', + ); + + openMultipleRecordPicker(recordPickerInstanceId); + + performSearch({ + multipleRecordPickerInstanceId: recordPickerInstanceId, + forceSearchFilter: '', + forceSearchableObjectMetadataItems: searchableObjectMetadataItems, + forcePickableMorphItems: pickableMorphItems, + }); + + pushFocusItemToFocusStack({ + focusId: recordPickerInstanceId, + component: { + type: FocusComponentType.DROPDOWN, + instanceId: recordPickerInstanceId, + }, + globalHotkeysConfig: { + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }); + }, + [openMultipleRecordPicker, performSearch, pushFocusItemToFocusStack], + ); + + return { openJunctionRelationFieldInput }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useUpdateJunctionRelationFromCell.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useUpdateJunctionRelationFromCell.ts new file mode 100644 index 0000000000..f858ea67b1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useUpdateJunctionRelationFromCell.ts @@ -0,0 +1,233 @@ +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; +import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; +import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { + type FieldRelationFromManyValue, + type FieldRelationMetadata, + type FieldRelationValue, +} from '@/object-record/record-field/ui/types/FieldMetadata'; +import { findJunctionRecordByTargetId } from '@/object-record/record-field/ui/utils/junction/findJunctionRecordByTargetId'; +import { findTargetFieldInfo } from '@/object-record/record-field/ui/utils/junction/findTargetFieldInfo'; +import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig'; +import { getSourceJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getSourceJoinColumnName'; +import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState'; +import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; + +type UseUpdateJunctionRelationFromCellArgs = { + fieldMetadataItem: FieldMetadataItem; + fieldDefinition: FieldDefinition; + recordId: string; +}; + +export const useUpdateJunctionRelationFromCell = ({ + fieldMetadataItem, + fieldDefinition, + recordId, +}: UseUpdateJunctionRelationFromCellArgs) => { + const { objectMetadataItems } = useObjectMetadataItems(); + + const sourceObjectMetadata = objectMetadataItems.find( + (item) => + item.nameSingular === fieldDefinition.metadata.objectMetadataNameSingular, + ); + + const junctionConfig = getJunctionConfig({ + settings: fieldMetadataItem.settings, + relationObjectMetadataId: fieldDefinition.metadata.relationObjectMetadataId, + sourceObjectMetadataId: sourceObjectMetadata?.id, + objectMetadataItems, + }); + + const junctionObjectMetadata = junctionConfig?.junctionObjectMetadata; + const sourceFieldOnJunction = junctionConfig?.sourceField; + + // Use relation object name as fallback to prevent hook errors (hooks can't be conditional) + const junctionObjectNameSingular = + junctionObjectMetadata?.nameSingular ?? + fieldDefinition.metadata.relationObjectMetadataNameSingular; + + // Skip the post-optimistic effect since we handle optimistic updates manually + // Otherwise Apollo would also add the record, resulting in duplicates + const { createOneRecord: createJunctionRecord } = useCreateOneRecord({ + objectNameSingular: junctionObjectNameSingular, + skipPostOptimisticEffect: true, + }); + + const { deleteOneRecord: deleteJunctionRecord } = useDeleteOneRecord({ + objectNameSingular: junctionObjectNameSingular, + }); + + const updateJunctionRelationFromCell = useRecoilCallback( + ({ snapshot, set }) => + async ({ morphItem }: { morphItem: RecordPickerPickableMorphItem }) => { + const targetFields = junctionConfig?.targetFields; + + if ( + !isDefined(junctionObjectMetadata) || + !isDefined(sourceFieldOnJunction) || + !isDefined(targetFields) || + targetFields.length === 0 + ) { + return; + } + + if (!isDefined(sourceObjectMetadata)) { + return; + } + + const sourceJoinColumnName = getSourceJoinColumnName({ + sourceField: sourceFieldOnJunction, + sourceObjectMetadata, + }); + + const fieldName = fieldDefinition.metadata.fieldName; + const junctionObjectName = junctionObjectMetadata.nameSingular; + + const targetFieldInfo = findTargetFieldInfo( + targetFields, + morphItem.objectMetadataId, + objectMetadataItems, + ); + + if (!isDefined(targetFieldInfo)) { + return; + } + + const targetFieldName = targetFieldInfo.fieldName; + const targetJoinColumnName = targetFieldInfo.joinColumnName; + + if ( + !isDefined(sourceJoinColumnName) || + !isDefined(targetJoinColumnName) + ) { + return; + } + + const currentJunctionRecords = + snapshot + .getLoadable< + FieldRelationValue + >(recordStoreFamilySelector({ recordId, fieldName })) + .getValue() ?? []; + + // morphItem.isSelected represents the NEW state (what the user wants) + if (!morphItem.isSelected) { + const junctionRecordToDelete = findJunctionRecordByTargetId({ + junctionRecords: currentJunctionRecords, + targetRecordId: morphItem.recordId, + targetFieldName, + }); + + if (!isDefined(junctionRecordToDelete)) { + return; + } + + await deleteJunctionRecord(junctionRecordToDelete.id); + + const currentFieldValue = snapshot + .getLoadable< + FieldRelationValue + >(recordStoreFamilySelector({ recordId, fieldName })) + .getValue(); + + if ( + !isDefined(currentFieldValue) || + !Array.isArray(currentFieldValue) + ) { + return; + } + + const updatedJunctionRecords = currentFieldValue.filter( + (record) => record.id !== junctionRecordToDelete.id, + ); + + set(recordStoreFamilyState(recordId), (currentRecord) => { + if (!isDefined(currentRecord)) { + return currentRecord; + } + return { + ...currentRecord, + [fieldName]: updatedJunctionRecords, + }; + }); + } else { + const searchRecord = snapshot + .getLoadable(searchRecordStoreFamilyState(morphItem.recordId)) + .getValue(); + + if (!isDefined(searchRecord?.record)) { + return; + } + + const targetRecord = searchRecord.record; + const newJunctionId = v4(); + const now = new Date().toISOString(); + + const junctionRecordForStore = { + id: newJunctionId, + createdAt: now, + updatedAt: now, + __typename: getObjectTypename(junctionObjectName), + [sourceJoinColumnName]: recordId, + [targetJoinColumnName]: morphItem.recordId, + [targetFieldName]: targetRecord, + }; + + const newJunctionRecordForApi = { + id: newJunctionId, + [sourceJoinColumnName]: recordId, + [targetJoinColumnName]: morphItem.recordId, + }; + + set(recordStoreFamilyState(recordId), (currentRecord) => { + if (!isDefined(currentRecord)) { + return currentRecord; + } + + const currentFieldValue = currentRecord[fieldName]; + const updatedJunctionRecords = Array.isArray(currentFieldValue) + ? [...currentFieldValue, junctionRecordForStore] + : [junctionRecordForStore]; + + return { + ...currentRecord, + [fieldName]: updatedJunctionRecords, + }; + }); + + await createJunctionRecord(newJunctionRecordForApi); + } + }, + [ + createJunctionRecord, + deleteJunctionRecord, + fieldDefinition.metadata.fieldName, + junctionConfig, + junctionObjectMetadata, + objectMetadataItems, + recordId, + sourceFieldOnJunction, + sourceObjectMetadata, + ], + ); + + const isJunctionConfigValid = + isDefined(junctionConfig) && + isDefined(sourceFieldOnJunction) && + isDefined(junctionConfig.targetFields) && + junctionConfig.targetFields.length > 0; + + return { + updateJunctionRelationFromCell, + isJunctionConfigValid, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx index 5eb35f3170..76ad0673e2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx @@ -1,16 +1,22 @@ +import { useContext } from 'react'; + import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords'; import { type NoteTarget } from '@/activities/types/NoteTarget'; import { type TaskTarget } from '@/activities/types/TaskTarget'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { RecordChip } from '@/object-record/components/RecordChip'; +import { isActivityTargetField } from '@/object-record/record-field-list/utils/categorizeRelationFields'; import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus'; import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useRelationFromManyFieldDisplay'; +import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction'; +import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig'; +import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; import styled from '@emotion/styled'; import { isArray } from '@sniptt/guards'; -import { useContext } from 'react'; import { isDefined } from 'twenty-shared/utils'; const StyledContainer = styled.div` @@ -28,12 +34,28 @@ export const RelationFromManyFieldDisplay = () => { useRelationFromManyFieldDisplay(); const { isFocused } = useFieldFocus(); const { disableChipClick, triggerEvent } = useContext(FieldContext); + const { objectMetadataItems } = useObjectMetadataItems(); const { fieldName, objectMetadataNameSingular } = fieldDefinition.metadata; const relationObjectNameSingular = fieldDefinition?.metadata.relationObjectMetadataNameSingular; + const isJunctionRelation = hasJunctionConfig( + fieldDefinition.metadata.settings, + ); + + const sourceObjectMetadataId = objectMetadataItems.find( + (item) => item.nameSingular === objectMetadataNameSingular, + )?.id; + + const junctionConfig = getJunctionConfig({ + settings: fieldDefinition.metadata.settings, + relationObjectMetadataId: fieldDefinition.metadata.relationObjectMetadataId, + sourceObjectMetadataId, + objectMetadataItems, + }); + const { activityTargetObjectRecords } = useActivityTargetObjectRecords( '', fieldValue as NoteTarget[] | TaskTarget[], @@ -47,15 +69,14 @@ export const RelationFromManyFieldDisplay = () => { return null; } - if (!isDefined(!relationObjectNameSingular)) { + if (!isDefined(relationObjectNameSingular)) { return null; } - const isRelationFromActivityTargets = - (fieldName === 'noteTargets' && - objectMetadataNameSingular === CoreObjectNameSingular.Note) || - (fieldName === 'taskTargets' && - objectMetadataNameSingular === CoreObjectNameSingular.Task); + const isRelationFromActivityTargets = isActivityTargetField( + fieldName, + objectMetadataNameSingular ?? '', + ); const isRelationFromManyActivities = (fieldName === 'noteTargets' && @@ -68,47 +89,77 @@ export const RelationFromManyFieldDisplay = () => { fieldName === 'noteTargets' ? CoreObjectNameSingular.Note : CoreObjectNameSingular.Task; - const relationFieldName = fieldName === 'noteTargets' ? 'note' : 'task'; - return isFocused ? ( + const chips = fieldValue + .map((record) => { + if (!isDefined(record) || !isDefined(record[relationFieldName])) { + return undefined; + } + return ( + + ); + }) + .filter(isDefined); + + if (isFocused) { + return ( + + {chips} + + ); + } + + return {chips}; + } + + if (isJunctionRelation && isDefined(junctionConfig)) { + const { targetFields } = junctionConfig; + + if (targetFields.length === 0) { + return null; + } + + const extractedRecords = extractTargetRecordsFromJunction({ + junctionRecords: fieldValue, + targetFields, + objectMetadataItems, + includeRecord: true, + }); + + const targetRecordsWithMetadata = extractedRecords + .map((extracted) => { + const objectMetadata = objectMetadataItems.find( + (item) => item.id === extracted.objectMetadataId, + ); + if (!objectMetadata || !extracted.record) { + return null; + } + return { record: extracted.record, objectMetadata }; + }) + .filter(isDefined); + + return ( - {fieldValue - ?.map((record) => { - if (!isDefined(record) || !isDefined(record[relationFieldName])) { - return undefined; - } - return ( - - ); - }) - .filter(isDefined)} + {targetRecordsWithMetadata.map(({ record, objectMetadata }) => ( + + ))} - ) : ( - - {fieldValue - ?.map((record) => { - if (!isDefined(record) || !isDefined(record[relationFieldName])) { - return undefined; - } - return ( - - ); - }) - .filter(isDefined)} - ); - } else if (isRelationFromActivityTargets) { + } + + if (isRelationFromActivityTargets) { return ( {activityTargetObjectRecords.filter(isDefined).map((record) => ( @@ -121,22 +172,22 @@ export const RelationFromManyFieldDisplay = () => { ))} ); - } else { - return ( - - {fieldValue?.filter(isDefined).map((record) => { - const recordChipData = generateRecordChipData(record); - return ( - - ); - })} - - ); } + + return ( + + {fieldValue.filter(isDefined).map((record) => { + const recordChipData = generateRecordChipData(record); + return ( + + ); + })} + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/utils/isFieldIdentifierDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/utils/isFieldIdentifierDisplay.ts index b4d3a1d9fb..1f5aab562e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/utils/isFieldIdentifierDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/utils/isFieldIdentifierDisplay.ts @@ -2,10 +2,14 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName'; import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber'; import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText'; +import { isFieldUuid } from '@/object-record/record-field/ui/types/guards/isFieldUuid'; export const isFieldIdentifierDisplay = ( field: Pick, isLabelIdentifier: boolean, ) => isLabelIdentifier && - (isFieldText(field) || isFieldFullName(field) || isFieldNumber(field)); + (isFieldText(field) || + isFieldFullName(field) || + isFieldNumber(field) || + isFieldUuid(field)); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useChipFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useChipFieldDisplay.ts index ee0e13b17e..fd910a2389 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useChipFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useChipFieldDisplay.ts @@ -2,6 +2,7 @@ import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/Pre import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName'; import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber'; import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText'; +import { isFieldUuid } from '@/object-record/record-field/ui/types/guards/isFieldUuid'; import { isNonEmptyString } from '@sniptt/guards'; import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; @@ -44,7 +45,8 @@ export const useChipFieldDisplay = () => { isFieldText(fieldDefinition) || isFieldFullName(fieldDefinition) || isFieldNumber(fieldDefinition) || - isFieldActor(fieldDefinition) + isFieldActor(fieldDefinition) || + isFieldUuid(fieldDefinition) ? fieldDefinition.metadata.objectMetadataNameSingular : undefined; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RelationOneToManyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RelationOneToManyFieldInput.tsx index b051dee978..2573fff996 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RelationOneToManyFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RelationOneToManyFieldInput.tsx @@ -1,4 +1,6 @@ import { useContext } from 'react'; +import { useRecoilCallback } from 'recoil'; +import { v4 } from 'uuid'; import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords'; import { useUpdateActivityTargetFromCell } from '@/activities/inline-cell/hooks/useUpdateActivityTargetFromCell'; @@ -6,10 +8,13 @@ import { type NoteTarget } from '@/activities/types/NoteTarget'; import { type TaskTarget } from '@/activities/types/TaskTarget'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById'; +import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; +import { isActivityTargetField } from '@/object-record/record-field-list/utils/categorizeRelationFields'; import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext'; +import { useUpdateJunctionRelationFromCell } from '@/object-record/record-field/ui/hooks/useUpdateJunctionRelationFromCell'; import { useRelationField } from '@/object-record/record-field/ui/meta-types/hooks/useRelationField'; import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer'; import { useUpdateRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateRelationOneToManyFieldInput'; @@ -17,13 +22,18 @@ import { RecordFieldComponentInstanceContext } from '@/object-record/record-fiel import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionComponentState'; import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { getJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getJoinColumnName'; +import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig'; +import { getSourceJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getSourceJoinColumnName'; +import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig'; import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker'; import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch'; import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { buildRecordLabelPayload } from '@/object-record/utils/buildRecordLabelPayload'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { useRecoilCallback } from 'recoil'; import { CustomError, isDefined } from 'twenty-shared/utils'; export const RelationOneToManyFieldInput = () => { @@ -63,11 +73,12 @@ export const RelationOneToManyFieldInput = () => { onSubmit?.({ skipPersist: true }); }; - const isRelationFromActivityTargets = - (fieldName === 'noteTargets' && - objectMetadataNameSingular === CoreObjectNameSingular.Note) || - (fieldName === 'taskTargets' && - objectMetadataNameSingular === CoreObjectNameSingular.Task); + const isRelationFromActivityTargets = isActivityTargetField( + fieldName, + objectMetadataNameSingular ?? '', + ); + + const isJunctionRelation = hasJunctionConfig(fieldMetadataItem.settings); const { activityTargetObjectRecords } = useActivityTargetObjectRecords( recordId, @@ -77,6 +88,36 @@ export const RelationOneToManyFieldInput = () => { const relationFieldDefinition = fieldDefinition as FieldDefinition; + const { updateJunctionRelationFromCell, isJunctionConfigValid } = + useUpdateJunctionRelationFromCell({ + fieldMetadataItem, + fieldDefinition: relationFieldDefinition, + recordId, + }); + + const junctionConfig = + isJunctionRelation && isJunctionConfigValid + ? getJunctionConfig({ + settings: fieldMetadataItem.settings, + relationObjectMetadataId: + relationFieldDefinition.metadata.relationObjectMetadataId, + sourceObjectMetadataId: objectMetadataItem.id, + objectMetadataItems, + }) + : null; + + const junctionTargetObjectMetadata = (() => { + if (!junctionConfig || junctionConfig.isMorphRelation) { + return undefined; + } + const firstTargetField = junctionConfig.targetFields[0]; + return objectMetadataItems.find( + (item) => item.id === firstTargetField?.relation?.targetObjectMetadata.id, + ); + })(); + + const isMorphJunction = junctionConfig?.isMorphRelation ?? false; + const { objectMetadataItem: relationObjectMetadataItem } = useObjectMetadataItem({ objectNameSingular: @@ -104,6 +145,17 @@ export const RelationOneToManyFieldInput = () => { recordId, }); + const { createOneRecord: createTargetRecord } = useCreateOneRecord({ + objectNameSingular: + junctionTargetObjectMetadata?.nameSingular ?? + relationFieldDefinition.metadata.relationObjectMetadataNameSingular, + }); + const { createOneRecord: createJunctionRecord } = useCreateOneRecord({ + objectNameSingular: + junctionConfig?.junctionObjectMetadata?.nameSingular ?? + relationFieldDefinition.metadata.relationObjectMetadataNameSingular, + }); + const layoutDirection = useRecoilComponentValue( recordFieldInputLayoutDirectionComponentState, ); @@ -119,43 +171,131 @@ export const RelationOneToManyFieldInput = () => { const handleCreateNew = useRecoilCallback( ({ snapshot, set }) => async (searchInput?: string) => { - const newRecordId = - await createNewRecordAndOpenRightDrawer?.(searchInput); + const updatePickerState = ( + newRecordId: string, + targetObjectMetadataId: string, + searchableObjectMetadataItems: (typeof relationObjectMetadataItem)[], + ) => { + const currentMorphItems = snapshot + .getLoadable(multipleRecordPickerPickableMorphItemsCallbackState) + .getValue(); - if (!isDefined(newRecordId)) { + const newMorphItems = currentMorphItems.concat({ + recordId: newRecordId, + objectMetadataId: targetObjectMetadataId, + isSelected: true, + isMatchingSearchFilter: true, + }); + + set( + multipleRecordPickerPickableMorphItemsCallbackState, + newMorphItems, + ); + + multipleRecordPickerPerformSearch({ + multipleRecordPickerInstanceId: instanceId, + forceSearchFilter: searchInput, + forceSearchableObjectMetadataItems: searchableObjectMetadataItems, + forcePickableMorphItems: newMorphItems, + }); + }; + + if ( + isJunctionRelation && + isDefined(junctionConfig) && + !isMorphJunction && + isDefined(junctionTargetObjectMetadata) + ) { + const { targetFields, sourceField } = junctionConfig; + const targetField = targetFields[0]; + + if (!targetField || !sourceField) { + return; + } + + const sourceJoinColumnName = getSourceJoinColumnName({ + sourceField, + sourceObjectMetadata: objectMetadataItem, + }); + + const targetJoinColumnName = getJoinColumnName(targetField.settings); + + if (!sourceJoinColumnName || !targetJoinColumnName) { + return; + } + + const newTargetId = v4(); + const targetPayload = buildRecordLabelPayload({ + id: newTargetId, + searchInput, + objectMetadataItem: junctionTargetObjectMetadata, + }); + + await createTargetRecord(targetPayload); + + const newJunctionId = v4(); + const createdJunction = await createJunctionRecord({ + id: newJunctionId, + [sourceJoinColumnName]: recordId, + [targetJoinColumnName]: newTargetId, + }); + + if (isDefined(createdJunction)) { + set(recordStoreFamilyState(recordId), (currentRecord) => { + if (!isDefined(currentRecord)) { + return currentRecord; + } + const currentFieldValue = currentRecord[fieldName]; + const updatedJunctionRecords = Array.isArray(currentFieldValue) + ? [...currentFieldValue, createdJunction] + : [createdJunction]; + + return { ...currentRecord, [fieldName]: updatedJunctionRecords }; + }); + } + + updatePickerState(newTargetId, junctionTargetObjectMetadata.id, [ + junctionTargetObjectMetadata, + ]); return; } - const multipleRecordPickerPickableMorphItems = snapshot - .getLoadable(multipleRecordPickerPickableMorphItemsCallbackState) - .getValue(); + const newRecordId = + await createNewRecordAndOpenRightDrawer?.(searchInput); - const newMorphItems = multipleRecordPickerPickableMorphItems.concat({ - recordId: newRecordId, - objectMetadataId: relationObjectMetadataItem.id, - isSelected: true, - isMatchingSearchFilter: true, - }); - - set(multipleRecordPickerPickableMorphItemsCallbackState, newMorphItems); - - multipleRecordPickerPerformSearch({ - multipleRecordPickerInstanceId: instanceId, - forceSearchFilter: searchInput, - forceSearchableObjectMetadataItems: [relationObjectMetadataItem], - forcePickableMorphItems: newMorphItems, - }); + if (isDefined(newRecordId)) { + updatePickerState(newRecordId, relationObjectMetadataItem.id, [ + relationObjectMetadataItem, + ]); + } }, [ createNewRecordAndOpenRightDrawer, - relationObjectMetadataItem, + createTargetRecord, + createJunctionRecord, + fieldName, instanceId, + isMorphJunction, + isJunctionRelation, + junctionConfig, + junctionTargetObjectMetadata, multipleRecordPickerPickableMorphItemsCallbackState, multipleRecordPickerPerformSearch, + objectMetadataItem, + recordId, + relationObjectMetadataItem, ], ); - const canCreateNew = !isRelationFromActivityTargets; + // Disable "Add New" for activity targets and MORPH junction relations + // (For MORPH, we don't know which object type to create) + const canCreateNew = !isRelationFromActivityTargets && !isMorphJunction; + + // For junction relations, use the target object for "Add New", not the junction object + const objectMetadataItemIdForCreate = + isJunctionRelation && isDefined(junctionTargetObjectMetadata) + ? junctionTargetObjectMetadata.id + : relationObjectMetadataItem.id; return ( { activityTargetWithTargetRecords: activityTargetObjectRecords, recordPickerInstanceId: instanceId, }); + } else if (isJunctionRelation && isJunctionConfigValid) { + updateJunctionRelationFromCell({ + morphItem, + }); } else { updateRelation(morphItem); } }} onCreate={canCreateNew ? handleCreateNew : undefined} - objectMetadataItemIdForCreate={relationObjectMetadataItem.id} + objectMetadataItemIdForCreate={objectMetadataItemIdForCreate} onClickOutside={handleSubmit} layoutDirection={ layoutDirection === 'downward' diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer.ts index 35138db34f..2ef825cf36 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer.ts @@ -6,12 +6,12 @@ import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordIn import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useUpdateOneRecordV2 } from '@/object-record/hooks/useUpdateOneRecordV2'; import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState'; +import { buildRecordLabelPayload } from '@/object-record/utils/buildRecordLabelPayload'; import { getOperationName } from '@apollo/client/utilities'; import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; @@ -62,28 +62,12 @@ export const useAddNewRecordAndOpenRightDrawer = ({ return { createNewRecordAndOpenRightDrawer: async (searchInput?: string) => { const newRecordId = v4(); - const labelIdentifierType = getLabelIdentifierFieldMetadataItem( - relationObjectMetadataItem, - )?.type; - const createRecordPayload: { - id: string; - name: - | string - | { firstName: string | undefined; lastName: string | undefined }; - [key: string]: any; - } = - labelIdentifierType === FieldMetadataType.FULL_NAME - ? { - id: newRecordId, - name: - searchInput && searchInput.split(' ').length > 1 - ? { - firstName: searchInput.split(' ')[0], - lastName: searchInput.split(' ').slice(1).join(' '), - } - : { firstName: searchInput, lastName: '' }, - } - : { id: newRecordId, name: searchInput ?? '' }; + + const createRecordPayload = buildRecordLabelPayload({ + id: newRecordId, + searchInput, + objectMetadataItem: relationObjectMetadataItem, + }); if (relationFieldMetadataItemRelationType === RelationType.MANY_TO_ONE) { const gqlField = diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts index 800579d13c..933d7a09a1 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts @@ -143,6 +143,15 @@ export type FieldPositionMetadata = BaseFieldMetadata & { settings?: null; }; +export type FieldRelationMetadataSettings = { + relationType?: RelationType; + // Join column name for the foreign key (e.g., "petId" for a "pet" relation) + joinColumnName?: string | null; + // Points to the target field on the junction object + // For MORPH_RELATION fields, morphRelations already contains all targets + junctionTargetFieldId?: string; +} | null; + // for later: refactor this in order to directly use relation without mapping export type FieldRelationMetadata = BaseFieldMetadata & { relationFieldMetadataId: string; @@ -152,14 +161,14 @@ export type FieldRelationMetadata = BaseFieldMetadata & { relationType?: RelationType; targetFieldMetadataName?: string; useEditButton?: boolean; - settings?: null; + settings?: FieldRelationMetadataSettings; }; export type FieldMorphRelationMetadata = BaseFieldMetadata & { morphRelations: FieldMetadataItemRelation[]; relationType: RelationType; useEditButton?: boolean; - settings?: null; + settings?: FieldRelationMetadataSettings; }; export type FieldSelectMetadata = BaseFieldMetadata & { diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/extractTargetRecordsFromJunction.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/extractTargetRecordsFromJunction.test.ts new file mode 100644 index 0000000000..31325e87e3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/extractTargetRecordsFromJunction.test.ts @@ -0,0 +1,343 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { RelationType } from '~/generated-metadata/graphql'; + +const createMockRelation = ( + targetObjectId: string, + targetObjectName: string, + sourceFieldName = 'sourceField', +): FieldMetadataItemRelation => ({ + type: RelationType.MANY_TO_ONE, + sourceFieldMetadata: { id: 'source-field-id', name: sourceFieldName }, + targetFieldMetadata: { + id: 'target-field-id', + name: 'targetField', + isCustom: false, + }, + sourceObjectMetadata: { + id: 'source-object-id', + nameSingular: 'sourceObject', + namePlural: 'sourceObjects', + }, + targetObjectMetadata: { + id: targetObjectId, + nameSingular: targetObjectName, + namePlural: `${targetObjectName}s`, + }, +}); + +const mockObjectMetadataItems: ObjectMetadataItem[] = [ + { + id: 'company-metadata-id', + nameSingular: 'company', + namePlural: 'companies', + } as ObjectMetadataItem, + { + id: 'person-metadata-id', + nameSingular: 'person', + namePlural: 'people', + } as ObjectMetadataItem, +]; + +const mockTargetField: FieldMetadataItem = { + id: 'target-field-id', + name: 'company', + type: FieldMetadataType.RELATION, + relation: createMockRelation('company-metadata-id', 'company'), +} as FieldMetadataItem; + +// Mock morph field with morphRelations (how real data looks) +const mockMorphFieldWithRelations: FieldMetadataItem = { + id: 'morph-field-id', + name: 'caretaker', + morphId: 'morph-group-1', + type: FieldMetadataType.MORPH_RELATION, + morphRelations: [ + createMockRelation('company-metadata-id', 'company', 'caretaker'), + createMockRelation('person-metadata-id', 'person', 'caretaker'), + ], +} as FieldMetadataItem; + +// Mock multiple regular relation fields (for multiple target testing) +const mockMultipleRelationFields: FieldMetadataItem[] = [ + { + id: 'relation-field-company-id', + name: 'company', + type: FieldMetadataType.RELATION, + relation: createMockRelation('company-metadata-id', 'company'), + } as FieldMetadataItem, + { + id: 'relation-field-person-id', + name: 'person', + type: FieldMetadataType.RELATION, + relation: createMockRelation('person-metadata-id', 'person'), + } as FieldMetadataItem, +]; + +const createMockJunctionRecord = ( + id: string, + targetData: Record, +): ObjectRecord => + ({ + id, + __typename: 'JunctionObject', + ...targetData, + }) as ObjectRecord; + +describe('extractTargetRecordsFromJunction', () => { + describe('with null/undefined junction records', () => { + it('should return empty array for undefined junction records', () => { + const result = extractTargetRecordsFromJunction({ + junctionRecords: undefined, + targetFields: [mockTargetField], + objectMetadataItems: mockObjectMetadataItems, + }); + expect(result).toEqual([]); + }); + + it('should return empty array for null junction records', () => { + const result = extractTargetRecordsFromJunction({ + junctionRecords: null, + targetFields: [mockTargetField], + objectMetadataItems: mockObjectMetadataItems, + }); + expect(result).toEqual([]); + }); + + it('should return empty array for empty targetFields', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: [], + objectMetadataItems: mockObjectMetadataItems, + }); + expect(result).toEqual([]); + }); + }); + + describe('with single target field (regular relations)', () => { + it('should extract target records from junction records', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + createMockJunctionRecord('junction-2', { + company: { id: 'company-2', name: 'Beta Inc' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: [mockTargetField], + objectMetadataItems: mockObjectMetadataItems, + }); + + expect(result).toEqual([ + { recordId: 'company-1', objectMetadataId: 'company-metadata-id' }, + { recordId: 'company-2', objectMetadataId: 'company-metadata-id' }, + ]); + }); + + it('should include record when includeRecord is true', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: [mockTargetField], + objectMetadataItems: mockObjectMetadataItems, + includeRecord: true, + }); + + expect(result).toEqual([ + { + recordId: 'company-1', + objectMetadataId: 'company-metadata-id', + record: { id: 'company-1', name: 'Acme Corp' }, + }, + ]); + }); + + it('should skip junction records with null target', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { company: null }), + createMockJunctionRecord('junction-2', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: [mockTargetField], + objectMetadataItems: mockObjectMetadataItems, + }); + + expect(result).toHaveLength(1); + expect(result[0].recordId).toBe('company-1'); + }); + }); + + describe('with multiple target fields (multiple regular relations)', () => { + it('should extract target records from different target fields', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + createMockJunctionRecord('junction-2', { + person: { id: 'person-1', name: 'John Doe' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: mockMultipleRelationFields, + objectMetadataItems: mockObjectMetadataItems, + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + recordId: 'company-1', + objectMetadataId: 'company-metadata-id', + }); + expect(result[1]).toEqual({ + recordId: 'person-1', + objectMetadataId: 'person-metadata-id', + }); + }); + + it('should return correct object metadata for each target field', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + person: { id: 'person-1', name: 'Jane Doe' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: mockMultipleRelationFields, + objectMetadataItems: mockObjectMetadataItems, + }); + + expect(result[0].objectMetadataId).toBe('person-metadata-id'); + }); + + it('should include record when includeRecord is true', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: mockMultipleRelationFields, + objectMetadataItems: mockObjectMetadataItems, + includeRecord: true, + }); + + expect(result[0]).toEqual({ + recordId: 'company-1', + objectMetadataId: 'company-metadata-id', + record: { id: 'company-1', name: 'Acme Corp' }, + }); + }); + }); + + describe('with morph relation field', () => { + // MORPH_RELATION fields use computed field names like "caretakerCompany", "caretakerPerson" + it('should extract target records from morph relation computed field names', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + caretakerCompany: { id: 'company-1', name: 'Acme Corp' }, + }), + createMockJunctionRecord('junction-2', { + caretakerPerson: { id: 'person-1', name: 'John Doe' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: [mockMorphFieldWithRelations], + objectMetadataItems: mockObjectMetadataItems, + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + recordId: 'company-1', + objectMetadataId: 'company-metadata-id', + }); + expect(result[1]).toEqual({ + recordId: 'person-1', + objectMetadataId: 'person-metadata-id', + }); + }); + + it('should include record when includeRecord is true for morph relations', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + caretakerCompany: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: [mockMorphFieldWithRelations], + objectMetadataItems: mockObjectMetadataItems, + includeRecord: true, + }); + + expect(result[0]).toEqual({ + recordId: 'company-1', + objectMetadataId: 'company-metadata-id', + record: { id: 'company-1', name: 'Acme Corp' }, + }); + }); + }); + + describe('edge cases', () => { + it('should skip undefined junction records in array', () => { + const junctionRecords = [ + undefined as unknown as ObjectRecord, + createMockJunctionRecord('junction-1', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: [mockTargetField], + objectMetadataItems: mockObjectMetadataItems, + }); + + expect(result).toHaveLength(1); + }); + + it('should return empty when no target field matches the junction record data', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + unknownField: { id: 'record-1', name: 'Unknown' }, + }), + ]; + + const result = extractTargetRecordsFromJunction({ + junctionRecords, + targetFields: [mockTargetField], + objectMetadataItems: mockObjectMetadataItems, + }); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/findJunctionRecordByTargetId.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/findJunctionRecordByTargetId.test.ts new file mode 100644 index 0000000000..6967c03ea5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/findJunctionRecordByTargetId.test.ts @@ -0,0 +1,134 @@ +import { findJunctionRecordByTargetId } from '@/object-record/record-field/ui/utils/junction/findJunctionRecordByTargetId'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; + +const createMockJunctionRecord = ( + id: string, + targetData: Record, +): ObjectRecord => + ({ + id, + __typename: 'JunctionObject', + ...targetData, + }) as ObjectRecord; + +describe('findJunctionRecordByTargetId', () => { + it('should return undefined for empty junction records', () => { + const result = findJunctionRecordByTargetId({ + junctionRecords: [], + targetRecordId: 'target-1', + targetFieldName: 'company', + }); + expect(result).toBeUndefined(); + }); + + it('should find junction record by target field name', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + createMockJunctionRecord('junction-2', { + company: { id: 'company-2', name: 'Beta Inc' }, + }), + ]; + + const result = findJunctionRecordByTargetId({ + junctionRecords, + targetRecordId: 'company-2', + targetFieldName: 'company', + }); + + expect(result?.id).toBe('junction-2'); + }); + + it('should return undefined when target record is not found', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const result = findJunctionRecordByTargetId({ + junctionRecords, + targetRecordId: 'non-existent', + targetFieldName: 'company', + }); + + expect(result).toBeUndefined(); + }); + + it('should skip undefined junction records', () => { + const junctionRecords = [ + undefined as unknown as ObjectRecord, + createMockJunctionRecord('junction-1', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const result = findJunctionRecordByTargetId({ + junctionRecords, + targetRecordId: 'company-1', + targetFieldName: 'company', + }); + + expect(result?.id).toBe('junction-1'); + }); + + it('should handle null target objects', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { company: null }), + createMockJunctionRecord('junction-2', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const result = findJunctionRecordByTargetId({ + junctionRecords, + targetRecordId: 'company-1', + targetFieldName: 'company', + }); + + expect(result?.id).toBe('junction-2'); + }); + + it('should handle target objects without id property', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + company: { name: 'No ID Company' }, + }), + createMockJunctionRecord('junction-2', { + company: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const result = findJunctionRecordByTargetId({ + junctionRecords, + targetRecordId: 'company-1', + targetFieldName: 'company', + }); + + expect(result?.id).toBe('junction-2'); + }); + + it('should work with different target field names', () => { + const junctionRecords = [ + createMockJunctionRecord('junction-1', { + person: { id: 'person-1', name: 'John Doe' }, + company: { id: 'company-1', name: 'Acme Corp' }, + }), + ]; + + const personResult = findJunctionRecordByTargetId({ + junctionRecords, + targetRecordId: 'person-1', + targetFieldName: 'person', + }); + expect(personResult?.id).toBe('junction-1'); + + const companyResult = findJunctionRecordByTargetId({ + junctionRecords, + targetRecordId: 'company-1', + targetFieldName: 'company', + }); + expect(companyResult?.id).toBe('junction-1'); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getJoinColumnName.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getJoinColumnName.test.ts new file mode 100644 index 0000000000..5df6828411 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getJoinColumnName.test.ts @@ -0,0 +1,30 @@ +import { getJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getJoinColumnName'; + +describe('getJoinColumnName', () => { + it('should return undefined for undefined settings', () => { + expect(getJoinColumnName(undefined)).toBeUndefined(); + }); + + it('should return undefined for null settings', () => { + expect(getJoinColumnName(null as unknown as undefined)).toBeUndefined(); + }); + + it('should return undefined for non-object settings', () => { + expect(getJoinColumnName('string' as unknown as undefined)).toBeUndefined(); + }); + + it('should return undefined for settings without joinColumnName', () => { + expect(getJoinColumnName({})).toBeUndefined(); + }); + + it('should return undefined for settings with non-string joinColumnName', () => { + expect(getJoinColumnName({ joinColumnName: 123 })).toBeUndefined(); + }); + + it('should return the joinColumnName for valid settings', () => { + expect(getJoinColumnName({ joinColumnName: 'personId' })).toBe('personId'); + expect(getJoinColumnName({ joinColumnName: 'companyId' })).toBe( + 'companyId', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getJunctionConfig.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getJunctionConfig.test.ts new file mode 100644 index 0000000000..9e084eb4be --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getJunctionConfig.test.ts @@ -0,0 +1,240 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { RelationType } from '~/generated-metadata/graphql'; + +const createMockRelation = ( + targetObjectId: string, + targetObjectName: string, + type: RelationType = RelationType.MANY_TO_ONE, +): FieldMetadataItemRelation => ({ + type, + sourceFieldMetadata: { id: 'source-field-id', name: 'sourceField' }, + targetFieldMetadata: { + id: 'target-field-id', + name: 'targetField', + isCustom: false, + }, + sourceObjectMetadata: { + id: 'source-object-id', + nameSingular: 'sourceObject', + namePlural: 'sourceObjects', + }, + targetObjectMetadata: { + id: targetObjectId, + nameSingular: targetObjectName, + namePlural: `${targetObjectName}s`, + }, +}); + +const createMockField = ( + overrides: Partial, +): FieldMetadataItem => + ({ + id: 'default-field-id', + name: 'defaultField', + type: FieldMetadataType.RELATION, + ...overrides, + }) as FieldMetadataItem; + +const createMockObjectMetadata = ( + overrides: Partial, +): ObjectMetadataItem => + ({ + id: 'default-object-id', + nameSingular: 'defaultObject', + namePlural: 'defaultObjects', + fields: [], + ...overrides, + }) as ObjectMetadataItem; + +describe('getJunctionConfig', () => { + describe('basic scenarios', () => { + it('should return null when junction object metadata not found', () => { + const result = getJunctionConfig({ + settings: { junctionTargetFieldId: 'field-id' }, + relationObjectMetadataId: 'non-existent-id', + objectMetadataItems: [], + }); + expect(result).toBeNull(); + }); + + it('should return null when settings has no junction config', () => { + const junctionObject = createMockObjectMetadata({ + id: 'junction-id', + fields: [], + }); + + const result = getJunctionConfig({ + settings: {}, + relationObjectMetadataId: 'junction-id', + objectMetadataItems: [junctionObject], + }); + expect(result).toBeNull(); + }); + + it('should return null when settings is undefined', () => { + const junctionObject = createMockObjectMetadata({ + id: 'junction-id', + }); + + const result = getJunctionConfig({ + settings: undefined, + relationObjectMetadataId: 'junction-id', + objectMetadataItems: [junctionObject], + }); + expect(result).toBeNull(); + }); + }); + + describe('junctionTargetFieldId configuration', () => { + it('should return targetFields for regular relation', () => { + const targetField = createMockField({ + id: 'target-field-id', + name: 'company', + type: FieldMetadataType.RELATION, + relation: createMockRelation('company-metadata-id', 'company'), + }); + const junctionObject = createMockObjectMetadata({ + id: 'junction-id', + fields: [targetField], + }); + const companyObject = createMockObjectMetadata({ + id: 'company-metadata-id', + nameSingular: 'company', + }); + + const result = getJunctionConfig({ + settings: { junctionTargetFieldId: 'target-field-id' }, + relationObjectMetadataId: 'junction-id', + objectMetadataItems: [junctionObject, companyObject], + }); + + expect(result).not.toBeNull(); + expect(result!.isMorphRelation).toBe(false); + expect(result!.targetFields).toHaveLength(1); + expect(result!.targetFields[0].name).toBe('company'); + }); + + it('should return null when target field not found', () => { + const junctionObject = createMockObjectMetadata({ + id: 'junction-id', + fields: [], + }); + + const result = getJunctionConfig({ + settings: { junctionTargetFieldId: 'non-existent-field' }, + relationObjectMetadataId: 'junction-id', + objectMetadataItems: [junctionObject], + }); + + expect(result).toBeNull(); + }); + + it('should return null for regular relation without relation property', () => { + const targetField = createMockField({ + id: 'target-field-id', + name: 'company', + type: FieldMetadataType.RELATION, + relation: undefined, + }); + const junctionObject = createMockObjectMetadata({ + id: 'junction-id', + fields: [targetField], + }); + + const result = getJunctionConfig({ + settings: { junctionTargetFieldId: 'target-field-id' }, + relationObjectMetadataId: 'junction-id', + objectMetadataItems: [junctionObject], + }); + + expect(result).toBeNull(); + }); + + it('should handle MORPH_RELATION field referenced by ID', () => { + const morphField = createMockField({ + id: 'morph-field-id', + name: 'linkedObject', + type: FieldMetadataType.MORPH_RELATION, + }); + const junctionObject = createMockObjectMetadata({ + id: 'junction-id', + fields: [morphField], + }); + + const result = getJunctionConfig({ + settings: { junctionTargetFieldId: 'morph-field-id' }, + relationObjectMetadataId: 'junction-id', + objectMetadataItems: [junctionObject], + }); + + expect(result).not.toBeNull(); + expect(result!.isMorphRelation).toBe(true); + expect(result!.targetFields).toHaveLength(1); + expect(result!.targetFields[0].name).toBe('linkedObject'); + }); + + it('should find sourceField excluding the target field', () => { + const sourceField = createMockField({ + id: 'source-field', + name: 'project', + type: FieldMetadataType.RELATION, + relation: createMockRelation('source-object-id', 'project'), + }); + const targetField = createMockField({ + id: 'target-field-id', + name: 'company', + type: FieldMetadataType.RELATION, + relation: createMockRelation('company-metadata-id', 'company'), + }); + const junctionObject = createMockObjectMetadata({ + id: 'junction-id', + fields: [sourceField, targetField], + }); + + const result = getJunctionConfig({ + settings: { junctionTargetFieldId: 'target-field-id' }, + relationObjectMetadataId: 'junction-id', + sourceObjectMetadataId: 'source-object-id', + objectMetadataItems: [junctionObject], + }); + + expect(result).not.toBeNull(); + expect(result!.sourceField).toBeDefined(); + expect(result!.sourceField!.name).toBe('project'); + }); + + it('should find sourceField from morph relation when sourceObjectMetadataId is provided', () => { + const sourceField = createMockField({ + id: 'source-field', + name: 'project', + type: FieldMetadataType.RELATION, + relation: createMockRelation('source-object-id', 'project'), + }); + const morphField = createMockField({ + id: 'morph-field-1', + name: 'company', + morphId: 'morph-group-1', + type: FieldMetadataType.MORPH_RELATION, + }); + const junctionObject = createMockObjectMetadata({ + id: 'junction-id', + fields: [sourceField, morphField], + }); + + const result = getJunctionConfig({ + settings: { junctionTargetFieldId: 'morph-field-1' }, + relationObjectMetadataId: 'junction-id', + sourceObjectMetadataId: 'source-object-id', + objectMetadataItems: [junctionObject], + }); + + expect(result).not.toBeNull(); + expect(result!.sourceField).toBeDefined(); + expect(result!.sourceField!.name).toBe('project'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getTargetObjectMetadataIdsFromField.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getTargetObjectMetadataIdsFromField.test.ts new file mode 100644 index 0000000000..0aef2f7098 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/getTargetObjectMetadataIdsFromField.test.ts @@ -0,0 +1,83 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { getTargetObjectMetadataIdsFromField } from '@/object-record/record-field/ui/utils/junction/getTargetObjectMetadataIdsFromField'; + +describe('getTargetObjectMetadataIdsFromField', () => { + it('should return empty array for field without morphRelations and relation', () => { + const field = { + type: FieldMetadataType.TEXT, + } as FieldMetadataItem; + + expect(getTargetObjectMetadataIdsFromField(field)).toEqual([]); + }); + + it('should return target id from relation field', () => { + const field = { + type: FieldMetadataType.RELATION, + relation: { + targetObjectMetadata: { + id: 'target-object-id', + }, + }, + } as FieldMetadataItem; + + expect(getTargetObjectMetadataIdsFromField(field)).toEqual([ + 'target-object-id', + ]); + }); + + it('should return empty array for relation field without target', () => { + const field = { + type: FieldMetadataType.RELATION, + relation: { + targetObjectMetadata: {}, + }, + } as unknown as FieldMetadataItem; + + expect(getTargetObjectMetadataIdsFromField(field)).toEqual([]); + }); + + it('should return target ids from morphRelations', () => { + const field = { + type: FieldMetadataType.MORPH_RELATION, + morphRelations: [ + { targetObjectMetadata: { id: 'target-1' } }, + { targetObjectMetadata: { id: 'target-2' } }, + ], + } as FieldMetadataItem; + + expect(getTargetObjectMetadataIdsFromField(field)).toEqual([ + 'target-1', + 'target-2', + ]); + }); + + it('should filter out undefined ids from morphRelations', () => { + const field = { + type: FieldMetadataType.MORPH_RELATION, + morphRelations: [ + { targetObjectMetadata: { id: 'target-1' } }, + { targetObjectMetadata: {} }, + ], + } as unknown as FieldMetadataItem; + + expect(getTargetObjectMetadataIdsFromField(field)).toEqual(['target-1']); + }); + + it('should prefer morphRelations over relation', () => { + const field = { + type: FieldMetadataType.MORPH_RELATION, + morphRelations: [{ targetObjectMetadata: { id: 'morph-target' } }], + relation: { + targetObjectMetadata: { + id: 'relation-target', + }, + }, + } as FieldMetadataItem; + + expect(getTargetObjectMetadataIdsFromField(field)).toEqual([ + 'morph-target', + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/hasJunctionConfig.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/hasJunctionConfig.test.ts new file mode 100644 index 0000000000..fbf518c79e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/hasJunctionConfig.test.ts @@ -0,0 +1,19 @@ +import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig'; + +describe('hasJunctionConfig', () => { + it('should return false for undefined settings', () => { + expect(hasJunctionConfig(undefined)).toBe(false); + }); + + it('should return false for settings without junctionTargetFieldId', () => { + expect(hasJunctionConfig({})).toBe(false); + }); + + it('should return false for settings with empty junctionTargetFieldId', () => { + expect(hasJunctionConfig({ junctionTargetFieldId: '' })).toBe(false); + }); + + it('should return true for settings with valid junctionTargetFieldId', () => { + expect(hasJunctionConfig({ junctionTargetFieldId: 'field-id' })).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/hasJunctionTargetFieldId.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/hasJunctionTargetFieldId.test.ts new file mode 100644 index 0000000000..1ebd61fd80 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/hasJunctionTargetFieldId.test.ts @@ -0,0 +1,37 @@ +import { hasJunctionTargetFieldId } from '@/object-record/record-field/ui/utils/junction/hasJunctionTargetFieldId'; + +describe('hasJunctionTargetFieldId', () => { + it('should return false for undefined settings', () => { + expect(hasJunctionTargetFieldId(undefined)).toBe(false); + }); + + it('should return false for null settings', () => { + expect(hasJunctionTargetFieldId(null as unknown as undefined)).toBe(false); + }); + + it('should return false for non-object settings', () => { + expect(hasJunctionTargetFieldId('string' as unknown as undefined)).toBe( + false, + ); + }); + + it('should return false for settings without junctionTargetFieldId', () => { + expect(hasJunctionTargetFieldId({})).toBe(false); + }); + + it('should return false for settings with non-string junctionTargetFieldId', () => { + expect(hasJunctionTargetFieldId({ junctionTargetFieldId: 123 })).toBe( + false, + ); + }); + + it('should return false for settings with empty string junctionTargetFieldId', () => { + expect(hasJunctionTargetFieldId({ junctionTargetFieldId: '' })).toBe(false); + }); + + it('should return true for settings with valid junctionTargetFieldId', () => { + expect( + hasJunctionTargetFieldId({ junctionTargetFieldId: 'some-field-id' }), + ).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isJunctionRelation.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isJunctionRelation.test.ts new file mode 100644 index 0000000000..1b3f79f09d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isJunctionRelation.test.ts @@ -0,0 +1,85 @@ +import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig'; +import { hasJunctionTargetFieldId } from '@/object-record/record-field/ui/utils/junction/hasJunctionTargetFieldId'; +import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField'; +import { FieldMetadataType } from 'twenty-shared/types'; + +describe('isJunctionRelation', () => { + describe('hasJunctionTargetFieldId', () => { + it.each([ + { settings: undefined, expected: false, desc: 'undefined' }, + { settings: null, expected: false, desc: 'null' }, + { settings: {}, expected: false, desc: 'empty object' }, + { + settings: { junctionTargetFieldId: '' }, + expected: false, + desc: 'empty string', + }, + { + settings: { junctionTargetFieldId: 'field-id' }, + expected: true, + desc: 'valid value', + }, + ])('returns $expected for $desc', ({ settings, expected }) => { + expect(hasJunctionTargetFieldId(settings)).toBe(expected); + }); + + it('narrows type correctly', () => { + const settings = { junctionTargetFieldId: 'field-id-1' }; + if (hasJunctionTargetFieldId(settings)) { + expect(settings.junctionTargetFieldId).toBe('field-id-1'); + } + }); + }); + + describe('hasJunctionConfig', () => { + it.each([ + { settings: undefined, expected: false, desc: 'undefined' }, + { settings: {}, expected: false, desc: 'empty object' }, + { + settings: { junctionTargetFieldId: 'field-id' }, + expected: true, + desc: 'fieldId set', + }, + { + settings: { junctionTargetFieldId: '' }, + expected: false, + desc: 'empty fieldId', + }, + ])('returns $expected for $desc', ({ settings, expected }) => { + expect(hasJunctionConfig(settings)).toBe(expected); + }); + }); + + describe('isJunctionRelationField', () => { + it.each([ + { + field: { type: FieldMetadataType.TEXT, settings: null }, + expected: false, + desc: 'non-relation field', + }, + { + field: { type: FieldMetadataType.RELATION, settings: null }, + expected: false, + desc: 'relation without junction config', + }, + { + field: { + type: FieldMetadataType.RELATION, + settings: { junctionTargetFieldId: 'field-id' }, + }, + expected: true, + desc: 'relation with junction config', + }, + { + field: { + type: FieldMetadataType.MORPH_RELATION, + settings: { junctionTargetFieldId: 'field-id' }, + }, + expected: false, + desc: 'morph relation (not supported)', + }, + ])('returns $expected for $desc', ({ field, expected }) => { + expect(isJunctionRelationField(field)).toBe(expected); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isObjectWithId.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isObjectWithId.test.ts new file mode 100644 index 0000000000..ee31a968fb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isObjectWithId.test.ts @@ -0,0 +1,32 @@ +import { isObjectWithId } from '@/object-record/record-field/ui/utils/junction/isObjectWithId'; + +describe('isObjectWithId', () => { + it('should return false for undefined', () => { + expect(isObjectWithId(undefined)).toBe(false); + }); + + it('should return false for null', () => { + expect(isObjectWithId(null)).toBe(false); + }); + + it('should return false for non-object values', () => { + expect(isObjectWithId('string')).toBe(false); + expect(isObjectWithId(123)).toBe(false); + expect(isObjectWithId(true)).toBe(false); + }); + + it('should return false for object without id property', () => { + expect(isObjectWithId({ name: 'test' })).toBe(false); + }); + + it('should return false for object with non-string id', () => { + expect(isObjectWithId({ id: 123 })).toBe(false); + expect(isObjectWithId({ id: null })).toBe(false); + expect(isObjectWithId({ id: undefined })).toBe(false); + }); + + it('should return true for object with string id', () => { + expect(isObjectWithId({ id: 'some-id' })).toBe(true); + expect(isObjectWithId({ id: 'uuid-123', name: 'test' })).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction.ts new file mode 100644 index 0000000000..e5bc06e271 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction.ts @@ -0,0 +1,138 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isObjectWithId } from '@/object-record/record-field/ui/utils/junction/isObjectWithId'; +import { type ExtractedTargetRecord } from '@/object-record/record-field/ui/utils/junction/types/ExtractedTargetRecord'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; + +type ExtractTargetRecordsFromJunctionArgs = { + junctionRecords: ObjectRecord[] | undefined | null; + targetFields: FieldMetadataItem[]; + objectMetadataItems: ObjectMetadataItem[]; + includeRecord?: boolean; +}; + +const tryExtractFromField = ( + junctionRecord: ObjectRecord, + fieldName: string, + objectMetadataId: string, + includeRecord: boolean, +): ExtractedTargetRecord | null => { + const targetObject = junctionRecord[fieldName]; + + if (!isObjectWithId(targetObject)) { + return null; + } + + return { + recordId: targetObject.id, + objectMetadataId, + ...(includeRecord && { record: targetObject }), + }; +}; + +const extractFromTargetFields = ( + junctionRecord: ObjectRecord, + targetFields: FieldMetadataItem[], + objectMetadataItems: ObjectMetadataItem[], + includeRecord: boolean, +): ExtractedTargetRecord | null => { + for (const targetField of targetFields) { + if (targetField.type === FieldMetadataType.MORPH_RELATION) { + const result = extractFromMorphRelationField( + junctionRecord, + targetField, + objectMetadataItems, + includeRecord, + ); + if (isDefined(result)) { + return result; + } + continue; + } + + const targetObjectMetadata = objectMetadataItems.find( + (item) => item.id === targetField.relation?.targetObjectMetadata.id, + ); + + if (isDefined(targetObjectMetadata)) { + const result = tryExtractFromField( + junctionRecord, + targetField.name, + targetObjectMetadata.id, + includeRecord, + ); + if (isDefined(result)) { + return result; + } + } + } + + return null; +}; + +const extractFromMorphRelationField = ( + junctionRecord: ObjectRecord, + targetField: FieldMetadataItem, + objectMetadataItems: ObjectMetadataItem[], + includeRecord: boolean, +): ExtractedTargetRecord | null => { + const morphRelations = targetField.morphRelations; + + if (!Array.isArray(morphRelations) || morphRelations.length === 0) { + return null; + } + + for (const morphRelation of morphRelations) { + const computedFieldName = computeMorphRelationFieldName({ + fieldName: morphRelation.sourceFieldMetadata.name, + relationType: morphRelation.type, + targetObjectMetadataNameSingular: + morphRelation.targetObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: + morphRelation.targetObjectMetadata.namePlural, + }); + + const targetObjectMetadata = objectMetadataItems.find( + (item) => item.id === morphRelation.targetObjectMetadata.id, + ); + + if (isDefined(targetObjectMetadata)) { + const result = tryExtractFromField( + junctionRecord, + computedFieldName, + targetObjectMetadata.id, + includeRecord, + ); + if (isDefined(result)) { + return result; + } + } + } + + return null; +}; + +export const extractTargetRecordsFromJunction = ({ + junctionRecords, + targetFields, + objectMetadataItems, + includeRecord = false, +}: ExtractTargetRecordsFromJunctionArgs): ExtractedTargetRecord[] => { + if (!Array.isArray(junctionRecords) || targetFields.length === 0) { + return []; + } + + return junctionRecords + .filter(isDefined) + .map((junctionRecord) => + extractFromTargetFields( + junctionRecord, + targetFields, + objectMetadataItems, + includeRecord, + ), + ) + .filter(isDefined); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/findJunctionRecordByTargetId.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/findJunctionRecordByTargetId.ts new file mode 100644 index 0000000000..5557028092 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/findJunctionRecordByTargetId.ts @@ -0,0 +1,29 @@ +import { isObjectWithId } from '@/object-record/record-field/ui/utils/junction/isObjectWithId'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from 'twenty-shared/utils'; + +type FindJunctionRecordByTargetIdArgs = { + junctionRecords: ObjectRecord[]; + targetRecordId: string; + targetFieldName: string; +}; + +export const findJunctionRecordByTargetId = ({ + junctionRecords, + targetRecordId, + targetFieldName, +}: FindJunctionRecordByTargetIdArgs): ObjectRecord | undefined => { + for (const junctionRecord of junctionRecords) { + if (!isDefined(junctionRecord)) { + continue; + } + + const targetObject = junctionRecord[targetFieldName]; + + if (isObjectWithId(targetObject) && targetObject.id === targetRecordId) { + return junctionRecord; + } + } + + return undefined; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/findTargetFieldInfo.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/findTargetFieldInfo.ts new file mode 100644 index 0000000000..f172741920 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/findTargetFieldInfo.ts @@ -0,0 +1,77 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getJoinColumnName'; +import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; + +export type TargetFieldInfo = { + fieldName: string; + joinColumnName?: string; +}; + +const findMorphTargetFieldInfo = ( + field: FieldMetadataItem, + targetObjectMetadataId: string, + objectMetadataItems: ObjectMetadataItem[], +): TargetFieldInfo | undefined => { + if (!isDefined(field.morphRelations) || field.morphRelations.length === 0) { + return undefined; + } + + const matchingMorphRelation = field.morphRelations.find( + (morphRelation) => + morphRelation.targetObjectMetadata.id === targetObjectMetadataId, + ); + + if (!isDefined(matchingMorphRelation)) { + return undefined; + } + + const targetObjectMetadata = objectMetadataItems.find( + (item) => item.id === targetObjectMetadataId, + ); + + if (!isDefined(targetObjectMetadata)) { + return undefined; + } + + const fieldName = computeMorphRelationFieldName({ + fieldName: matchingMorphRelation.sourceFieldMetadata.name, + relationType: matchingMorphRelation.type, + targetObjectMetadataNameSingular: targetObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: targetObjectMetadata.namePlural, + }); + + // For morph relations, compute the join column name from the computed field name + // e.g., caretakerPerson → caretakerPersonId + return { + fieldName, + joinColumnName: `${fieldName}Id`, + }; +}; + +export const findTargetFieldInfo = ( + targetFields: FieldMetadataItem[], + targetObjectMetadataId: string, + objectMetadataItems: ObjectMetadataItem[], +): TargetFieldInfo | undefined => { + for (const field of targetFields) { + const morphResult = findMorphTargetFieldInfo( + field, + targetObjectMetadataId, + objectMetadataItems, + ); + + if (isDefined(morphResult)) { + return morphResult; + } + + if (field.relation?.targetObjectMetadata.id === targetObjectMetadataId) { + return { + fieldName: field.name, + joinColumnName: getJoinColumnName(field.settings), + }; + } + } + + return undefined; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getJoinColumnName.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getJoinColumnName.ts new file mode 100644 index 0000000000..6986073e29 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getJoinColumnName.ts @@ -0,0 +1,16 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isDefined } from 'twenty-shared/utils'; + +export const getJoinColumnName = ( + settings: FieldMetadataItem['settings'], +): string | undefined => { + if ( + isDefined(settings) && + typeof settings === 'object' && + 'joinColumnName' in settings && + typeof settings.joinColumnName === 'string' + ) { + return settings.joinColumnName; + } + return undefined; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getJunctionConfig.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getJunctionConfig.ts new file mode 100644 index 0000000000..fb503259c9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getJunctionConfig.ts @@ -0,0 +1,98 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { hasJunctionTargetFieldId } from './hasJunctionTargetFieldId'; + +export type JunctionObjectMetadataItem = Pick< + ObjectMetadataItem, + | 'id' + | 'fields' + | 'labelIdentifierFieldMetadataId' + | 'imageIdentifierFieldMetadataId' + | 'nameSingular' + | 'namePlural' +>; + +export type JunctionConfig = { + junctionObjectMetadata: JunctionObjectMetadataItem; + targetFields: FieldMetadataItem[]; + sourceField?: FieldMetadataItem; + isMorphRelation: boolean; +}; + +type GetJunctionConfigArgs = { + settings: FieldMetadataItem['settings'] | undefined; + relationObjectMetadataId: string; + sourceObjectMetadataId?: string; + objectMetadataItems: JunctionObjectMetadataItem[]; +}; + +export const getJunctionConfig = ({ + settings, + relationObjectMetadataId, + sourceObjectMetadataId, + objectMetadataItems, +}: GetJunctionConfigArgs): JunctionConfig | null => { + const junctionObjectMetadata = objectMetadataItems.find( + (item) => item.id === relationObjectMetadataId, + ); + + if (!isDefined(junctionObjectMetadata)) { + return null; + } + + const findSourceField = ( + excludeFieldId?: string, + ): FieldMetadataItem | undefined => { + if (!isDefined(sourceObjectMetadataId)) { + return undefined; + } + + const relationField = junctionObjectMetadata.fields.find( + (field) => + field.type === FieldMetadataType.RELATION && + field.relation?.targetObjectMetadata.id === sourceObjectMetadataId && + field.id !== excludeFieldId, + ); + + if (isDefined(relationField)) { + return relationField; + } + + return junctionObjectMetadata.fields.find( + (field) => + field.type === FieldMetadataType.MORPH_RELATION && + field.id !== excludeFieldId && + field.morphRelations?.some( + (morphRelation) => + morphRelation.targetObjectMetadata.id === sourceObjectMetadataId, + ), + ); + }; + + if (!hasJunctionTargetFieldId(settings)) { + return null; + } + + const targetField = junctionObjectMetadata.fields.find( + (field) => field.id === settings.junctionTargetFieldId, + ); + + if (!isDefined(targetField)) { + return null; + } + + const isMorphRelation = targetField.type === FieldMetadataType.MORPH_RELATION; + + if (!isMorphRelation && !isDefined(targetField.relation)) { + return null; + } + + return { + junctionObjectMetadata, + targetFields: [targetField], + sourceField: findSourceField(settings.junctionTargetFieldId), + isMorphRelation, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getSearchableObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getSearchableObjectMetadataItems.ts new file mode 100644 index 0000000000..9d5436b2ca --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getSearchableObjectMetadataItems.ts @@ -0,0 +1,18 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getTargetObjectMetadataIdsFromField } from './getTargetObjectMetadataIdsFromField'; +import { isDefined } from 'twenty-shared/utils'; + +export const getSearchableObjectMetadataItems = ( + targetFields: FieldMetadataItem[], + objectMetadataItems: ObjectMetadataItem[], +): ObjectMetadataItem[] => { + const targetObjectIds = targetFields.flatMap( + getTargetObjectMetadataIdsFromField, + ); + const uniqueTargetObjectIds = [...new Set(targetObjectIds)]; + + return uniqueTargetObjectIds + .map((id) => objectMetadataItems.find((item) => item.id === id)) + .filter(isDefined); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getSourceJoinColumnName.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getSourceJoinColumnName.ts new file mode 100644 index 0000000000..a6c6dc910f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getSourceJoinColumnName.ts @@ -0,0 +1,39 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getJoinColumnName'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; + +type GetSourceJoinColumnNameArgs = { + sourceField: Pick; + sourceObjectMetadata: Pick< + ObjectMetadataItem, + 'id' | 'nameSingular' | 'namePlural' + >; +}; + +export const getSourceJoinColumnName = ({ + sourceField, + sourceObjectMetadata, +}: GetSourceJoinColumnNameArgs): string | undefined => { + if (sourceField.type === FieldMetadataType.MORPH_RELATION) { + const morphRelation = sourceField.morphRelations?.find( + (mr) => mr.targetObjectMetadata.id === sourceObjectMetadata.id, + ); + + if (!isDefined(morphRelation)) { + return undefined; + } + + const computedFieldName = computeMorphRelationFieldName({ + fieldName: morphRelation.sourceFieldMetadata.name, + relationType: morphRelation.type, + targetObjectMetadataNameSingular: sourceObjectMetadata.nameSingular, + targetObjectMetadataNamePlural: sourceObjectMetadata.namePlural, + }); + + return `${computedFieldName}Id`; + } + + return getJoinColumnName(sourceField.settings); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getTargetObjectMetadataIdsFromField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getTargetObjectMetadataIdsFromField.ts new file mode 100644 index 0000000000..a41111d1d9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/getTargetObjectMetadataIdsFromField.ts @@ -0,0 +1,15 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isDefined } from 'twenty-shared/utils'; + +export const getTargetObjectMetadataIdsFromField = ( + field: FieldMetadataItem, +): string[] => { + if (isDefined(field.morphRelations) && field.morphRelations.length > 0) { + return field.morphRelations + .map((morphRelation) => morphRelation.targetObjectMetadata.id) + .filter(isDefined); + } + + const targetId = field.relation?.targetObjectMetadata.id; + return targetId ? [targetId] : []; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/hasJunctionConfig.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/hasJunctionConfig.ts new file mode 100644 index 0000000000..44c4ad9f3c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/hasJunctionConfig.ts @@ -0,0 +1,12 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type FieldRelationMetadataSettings } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { hasJunctionTargetFieldId } from './hasJunctionTargetFieldId'; + +type FieldMetadataItemSettings = FieldMetadataItem['settings']; +type SettingsInput = + | FieldMetadataItemSettings + | FieldRelationMetadataSettings + | undefined; + +export const hasJunctionConfig = (settings: SettingsInput): boolean => + hasJunctionTargetFieldId(settings); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/hasJunctionTargetFieldId.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/hasJunctionTargetFieldId.ts new file mode 100644 index 0000000000..642148dacc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/hasJunctionTargetFieldId.ts @@ -0,0 +1,27 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type FieldRelationMetadataSettings } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { isDefined } from 'twenty-shared/utils'; + +type FieldMetadataItemSettings = FieldMetadataItem['settings']; +type SettingsInput = + | FieldMetadataItemSettings + | FieldRelationMetadataSettings + | undefined; + +type SettingsWithJunctionTargetFieldId = + NonNullable & { + junctionTargetFieldId: string; + }; + +export const hasJunctionTargetFieldId = ( + settings: SettingsInput, +): settings is SettingsWithJunctionTargetFieldId => { + if (!isDefined(settings) || typeof settings !== 'object') { + return false; + } + return ( + 'junctionTargetFieldId' in settings && + typeof settings.junctionTargetFieldId === 'string' && + settings.junctionTargetFieldId.length > 0 + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/isJunctionRelationField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/isJunctionRelationField.ts new file mode 100644 index 0000000000..332c0f78b0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/isJunctionRelationField.ts @@ -0,0 +1,9 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { hasJunctionConfig } from './hasJunctionConfig'; + +export const isJunctionRelationField = ( + fieldMetadataItem: Pick, +): boolean => + fieldMetadataItem.type === FieldMetadataType.RELATION && + hasJunctionConfig(fieldMetadataItem.settings); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/isObjectWithId.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/isObjectWithId.ts new file mode 100644 index 0000000000..eede13b078 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/isObjectWithId.ts @@ -0,0 +1,8 @@ +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from 'twenty-shared/utils'; + +export const isObjectWithId = (value: unknown): value is ObjectRecord => + isDefined(value) && + typeof value === 'object' && + 'id' in value && + typeof value.id === 'string'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/types/ExtractedTargetRecord.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/types/ExtractedTargetRecord.ts new file mode 100644 index 0000000000..b4a20ffee3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/junction/types/ExtractedTargetRecord.ts @@ -0,0 +1,7 @@ +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; + +export type ExtractedTargetRecord = { + recordId: string; + objectMetadataId: string; + record?: ObjectRecord; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx index 94719c5036..bb84725723 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay'; +import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus'; import { useRecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext'; import { RecordInlineCellDisplayMode } from '@/object-record/record-inline-cell/components/RecordInlineCellDisplayMode'; import { RecordInlineCellSkeletonLoader } from '@/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader'; @@ -29,7 +30,9 @@ const StyledClickableContainer = styled.div<{ `; export const RecordInlineCellValue = () => { - const { readonly, loading, isCentered } = useRecordInlineCellContext(); + const { readonly, loading, isCentered, onOpenEditMode } = + useRecordInlineCellContext(); + const { isFocused } = useFieldFocus(); if (loading === true) { return ; @@ -37,7 +40,10 @@ export const RecordInlineCellValue = () => { return ( - + diff --git a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellFieldDisplay.tsx index ff5193af04..14cf35d4b9 100644 --- a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellFieldDisplay.tsx @@ -1,8 +1,10 @@ import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName'; import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText'; +import { isFieldUuid } from '@/object-record/record-field/ui/types/guards/isFieldUuid'; import { RecordTitleCellSingleTextDisplayMode } from '@/object-record/record-title-cell/components/RecordTitleCellTextFieldDisplay'; import { RecordTitleFullNameFieldDisplay } from '@/object-record/record-title-cell/components/RecordTitleFullNameFieldDisplay'; +import { RecordTitleCellUuidFieldDisplay } from '@/object-record/record-title-cell/components/RecordTitleCellUuidFieldDisplay'; import { type RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { useContext } from 'react'; @@ -13,8 +15,12 @@ export const RecordTitleCellFieldDisplay = ({ }) => { const { fieldDefinition } = useContext(FieldContext); - if (!isFieldText(fieldDefinition) && !isFieldFullName(fieldDefinition)) { - throw new Error('Field definition is not a text or full name field'); + if ( + !isFieldText(fieldDefinition) && + !isFieldFullName(fieldDefinition) && + !isFieldUuid(fieldDefinition) + ) { + throw new Error('Field definition is not a text, full name, or UUID field'); } return ( @@ -23,6 +29,8 @@ export const RecordTitleCellFieldDisplay = ({ ) : isFieldFullName(fieldDefinition) ? ( + ) : isFieldUuid(fieldDefinition) ? ( + ) : null} ); diff --git a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellUuidFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellUuidFieldDisplay.tsx new file mode 100644 index 0000000000..a294fcde59 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellUuidFieldDisplay.tsx @@ -0,0 +1,39 @@ +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { type RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; +import styled from '@emotion/styled'; +import { useContext } from 'react'; +import { useRecoilValue } from 'recoil'; +import { OverflowingTextWithTooltip } from 'twenty-ui/display'; + +const StyledDiv = styled.div` + align-items: center; + background: inherit; + border: none; + border-radius: ${({ theme }) => theme.border.radius.sm}; + box-sizing: border-box; + color: ${({ theme }) => theme.font.color.primary}; + display: flex; + height: 24px; + justify-content: center; + overflow: hidden; + padding: ${({ theme }) => theme.spacing(0, 1.25)}; +`; + +export const RecordTitleCellUuidFieldDisplay = ({ + containerType: _containerType, +}: { + containerType: RecordTitleCellContainerType; +}) => { + const { recordId, fieldDefinition } = useContext(FieldContext); + + const recordValue = useRecoilValue(recordStoreFamilyState(recordId)); + + const uuidValue = recordValue?.[fieldDefinition.metadata.fieldName] ?? ''; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/buildRecordLabelPayload.ts b/packages/twenty-front/src/modules/object-record/utils/buildRecordLabelPayload.ts new file mode 100644 index 0000000000..5fcfc92181 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/buildRecordLabelPayload.ts @@ -0,0 +1,32 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; +import { FieldMetadataType } from 'twenty-shared/types'; + +type BuildRecordLabelPayloadArgs = { + id: string; + searchInput?: string; + objectMetadataItem: ObjectMetadataItem; +}; + +export const buildRecordLabelPayload = ({ + id, + searchInput, + objectMetadataItem, +}: BuildRecordLabelPayloadArgs): Record => { + const labelIdentifierField = + getLabelIdentifierFieldMetadataItem(objectMetadataItem); + + if (labelIdentifierField?.type === FieldMetadataType.FULL_NAME) { + const words = searchInput?.split(' ') ?? []; + const hasMultipleWords = words.length > 1; + + return { + id, + name: hasMultipleWords + ? { firstName: words[0], lastName: words.slice(1).join(' ') } + : { firstName: searchInput ?? '', lastName: '' }, + }; + } + + return { id, name: searchInput ?? '' }; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts b/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts index fc5c21e627..c3e082de70 100644 --- a/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts +++ b/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts @@ -9,9 +9,5 @@ export const filterAvailableTableColumns = ( return false; } - if (columnDefinition.type === 'UUID') { - return false; - } - return true; }; diff --git a/packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts b/packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts index 32e81f3710..e6996be403 100644 --- a/packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts +++ b/packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts @@ -9,11 +9,9 @@ export const isFieldCellSupported = ( objectMetadataItems: ObjectMetadataItem[], ) => { if ( - [ - FieldMetadataType.UUID, - FieldMetadataType.POSITION, - FieldMetadataType.RICH_TEXT, - ].includes(fieldMetadataItem.type) + [FieldMetadataType.POSITION, FieldMetadataType.RICH_TEXT].includes( + fieldMetadataItem.type, + ) ) { return false; } diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx index 23d001f8c7..b1b7c89deb 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx @@ -59,6 +59,12 @@ const dateTimeFieldFormSchema = z .extend(settingsDataModelFieldDateFormSchema.shape) .extend(isUniqueFieldFormSchema.shape); +const relationFieldFormSchema = z + .object({ + type: z.literal(FieldMetadataType.RELATION), + }) + .extend(settingsDataModelFieldMorphRelationFormSchema.shape); + const morphRelationFieldFormSchema = z .object({ type: z.literal(FieldMetadataType.MORPH_RELATION), @@ -128,6 +134,7 @@ const otherFieldsFormSchema = z omit(SETTINGS_FIELD_TYPE_CONFIGS, [ FieldMetadataType.BOOLEAN, FieldMetadataType.CURRENCY, + FieldMetadataType.RELATION, FieldMetadataType.MORPH_RELATION, FieldMetadataType.SELECT, FieldMetadataType.MULTI_SELECT, @@ -154,6 +161,7 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion( currencyFieldFormSchema, dateFieldFormSchema, dateTimeFieldFormSchema, + relationFieldFormSchema, morphRelationFieldFormSchema, selectFieldFormSchema, multiSelectFieldFormSchema, diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationForm.tsx index d8b65ad66e..3baa651cad 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationForm.tsx @@ -88,8 +88,7 @@ export const SettingsDataModelFieldRelationForm = ({ }); const initialRelationType = - existingFieldMetadataItem?.settings?.relationType ?? - RelationType.ONE_TO_MANY; + existingFieldMetadataItem?.relation?.type ?? RelationType.ONE_TO_MANY; const { label: defaultLabelOnDestination, icon: defaultIconOnDestination } = useRelationSettingsFormDefaultValuesTargetFieldMetadata({ diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationFormCard.tsx index fee38505ef..2153b96584 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationFormCard.tsx @@ -7,12 +7,15 @@ import { SettingsDataModelFieldRelationForm, type SettingsDataModelFieldMorphRelationFormValues, } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationForm'; +import { SettingsDataModelFieldRelationJunctionForm } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationJunctionForm'; import { SettingsDataModelFieldRelationPreviewContent } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationPreviewContent'; import { SettingsDataModelRelationPreviewImage } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationPreviewImageCard'; import { SettingsDataModelRelationFieldPreviewSubWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelRelationFieldPreviewSubWidget'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; +import { FeatureFlagKey } from '~/generated/graphql'; import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit'; type SettingsDataModelFieldRelationFormCardProps = { @@ -31,6 +34,9 @@ export const SettingsDataModelFieldRelationFormCard = ({ SettingsDataModelFieldEditFormValues >(); const isMobile = useIsMobile(); + const isJunctionRelationsEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_JUNCTION_RELATIONS_ENABLED, + ); const { objectMetadataItems } = useObjectMetadataItems(); @@ -107,10 +113,17 @@ export const SettingsDataModelFieldRelationFormCard = ({ } form={ - + <> + + {isJunctionRelationsEnabled && ( + + )} + } /> ); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationJunctionForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationJunctionForm.tsx new file mode 100644 index 0000000000..3c8b796afb --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationJunctionForm.tsx @@ -0,0 +1,163 @@ +import { useFormContext } from 'react-hook-form'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; +import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; +import { Select } from '@/ui/input/components/Select'; +import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState'; +import { useLingui } from '@lingui/react/macro'; +import { useRecoilValue } from 'recoil'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { IconLink } from 'twenty-ui/display'; +import { RelationType } from '~/generated-metadata/graphql'; +import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit'; + +type SettingsDataModelFieldRelationJunctionFormProps = { + objectNameSingular: string; +}; + +export const SettingsDataModelFieldRelationJunctionForm = ({ + objectNameSingular, +}: SettingsDataModelFieldRelationJunctionFormProps) => { + const { t } = useLingui(); + const { watch, setValue } = + useFormContext(); + + const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState); + + const { objectMetadataItem: sourceObjectMetadataItem } = + useObjectMetadataItem({ objectNameSingular }); + + const { objectMetadataItems } = useObjectMetadataItems(); + + const relationType = watch('relationType') ?? RelationType.ONE_TO_MANY; + const targetObjectIds = watch('morphRelationObjectMetadataIds') ?? []; + const junctionTargetFieldId = watch('settings.junctionTargetFieldId'); + + // Only applies to ONE_TO_MANY with single target + if ( + !isAdvancedModeEnabled || + relationType !== RelationType.ONE_TO_MANY || + targetObjectIds.length !== 1 + ) { + return null; + } + + const junctionObjectMetadataItem = objectMetadataItems.find( + (item) => item.id === targetObjectIds[0], + ); + + if (!junctionObjectMetadataItem) { + return null; + } + + const sourceObjectMetadataId = sourceObjectMetadataItem?.id; + + // Self-referential relations cannot be junction objects + if (sourceObjectMetadataId === junctionObjectMetadataItem.id) { + return null; + } + + // Build options from junction object fields + const junctionFieldOptions: { label: string; value: string }[] = []; + + // Add MORPH_RELATION fields (use first field of each morphId group) + // morphRelations already contains all targets, so any sibling works + const morphIdsSeen = new Set(); + junctionObjectMetadataItem.fields + .filter( + (field) => + field.type === FieldMetadataType.MORPH_RELATION && + isDefined(field.morphId), + ) + .forEach((field) => { + if (!morphIdsSeen.has(field.morphId!)) { + morphIdsSeen.add(field.morphId!); + junctionFieldOptions.push({ + label: `${field.label} (polymorphic)`, + value: field.id, + }); + } + }); + + // Add regular MANY_TO_ONE relations (not pointing back to source) + junctionObjectMetadataItem.fields + .filter((field) => { + if ( + field.type !== FieldMetadataType.RELATION || + field.relation?.type !== RelationType.MANY_TO_ONE + ) { + return false; + } + return ( + !isDefined(sourceObjectMetadataId) || + field.relation?.targetObjectMetadata.id !== sourceObjectMetadataId + ); + }) + .forEach((field) => { + junctionFieldOptions.push({ + label: field.label, + value: field.id, + }); + }); + + if (junctionFieldOptions.length === 0) { + return null; + } + + const isJunctionConfigEnabled = isDefined(junctionTargetFieldId); + + const handleJunctionToggle = (checked: boolean) => { + if (checked && junctionFieldOptions.length > 0) { + setValue( + 'settings.junctionTargetFieldId', + junctionFieldOptions[0].value, + { + shouldDirty: true, + }, + ); + } else { + setValue('settings.junctionTargetFieldId', undefined, { + shouldDirty: true, + }); + } + }; + + const handleSelectionChange = (selectedValue: string) => { + setValue('settings.junctionTargetFieldId', selectedValue, { + shouldDirty: true, + }); + }; + + return ( + <> + + + {isJunctionConfigEnabled && ( + +