From 6afc3d33a4877e875f64e709ea50544a5e4eb74b Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:23:12 +0200 Subject: [PATCH] fix: provision INDEX view fields for relations created in the same batch as their object (#23665) ## Context Fixes twentyhq/core-team-issues#2749: when an app manifest creates an object and its relation fields in a single sync, the engine-owned INDEX view ended up with no viewField at all for RELATION / MORPH_RELATION fields, not even a hidden one. Adding the same relation to a pre-existing object in a second sync produced a visible viewField. ## Root cause `fieldIndexViewFieldOnCreate` is the sole owner of caller-field view fields on the INDEX view (`objectSystemFieldsAndIndexViewOnCreate` only emits view fields for displayable system fields). Its same-batch branch gated on `isFlatFieldMetadataDisplayableInDefaultView`, which excludes RELATION / MORPH_RELATION, so relations were dropped and nothing else picked them up. The guard's other exclusions (reserved names like `id`/`deletedAt`, system-only types TS_VECTOR / POSITION) are unreachable there: side effect handlers only trigger on caller-authored entities (the engine reads triggers from the pre-expansion matrix), and the flat field validators reject those names/types for caller fields. The guard could only ever drop relations. ## Fix - Remove the displayability guard from `buildViewFieldForObjectCreatedInSameBatch`. - Remove the `displayableOnly` filter from `computeCallerFlatFieldMetadatasForObject`: every caller field now gets a view field, and both handlers keep deriving positions from the same list, so the interleaved layout stays consistent by construction (label identifier, caller fields in input order with relations, then displayable system fields). - Build the view field literal through a single `buildIndexFlatViewFieldToCreate` helper on all handler branches instead of `computeFlatViewFieldsToCreate`, whose internal displayability filter would have dropped relations again. That util keeps its semantics for its remaining callers (system-field view fields, object creation via API, committed upgrade commands). Both identifier derivations are the same deterministic uuid (asserted by an existing twenty-shared spec), so emitted identifiers are unchanged. - `isFlatFieldMetadataDisplayableInDefaultView` itself is untouched: the committed 2-26 upgrade command and the system-field filtering still rely on its current semantics. ## Tests - New manifest-sync integration test (first commit, TDD red then green): a single sync creating two objects and a MANY_TO_ONE / ONE_TO_MANY relation pair asserts each object's INDEX view has a visible view field for its relation, plus a control case adding the same relations to pre-existing objects in a second sync. - Unit spec: the test that locked in the noop now asserts a visible view field at the expected position for RELATION and MORPH_RELATION. - Verified locally: all 62 metadata-side-effect unit tests, the new integration spec, `successful-sync-application-workspace-migration` (4 snapshots), `relabel-onto-new-field-manifest-sync`, `create-one-field-metadata-relation`, plus twenty-server typecheck and lint. --- ...create-side-effect-handler.service.spec.ts | 60 ++++- ...d-on-create-side-effect-handler.service.ts | 78 +++--- ...w-on-create-side-effect-handler.service.ts | 16 +- ...er-flat-field-metadatas-for-object.util.ts | 10 +- ...tion-by-field-universal-identifier.util.ts | 21 +- ...ew-field-manifest-sync.integration-spec.ts | 249 ++++++++++++++++++ 6 files changed, 348 insertions(+), 86 deletions(-) create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/same-batch-relation-index-view-field-manifest-sync.integration-spec.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/__tests__/field-index-view-field-on-create-side-effect-handler.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/__tests__/field-index-view-field-on-create-side-effect-handler.service.spec.ts index 1f789bf79c..7c6860779a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/__tests__/field-index-view-field-on-create-side-effect-handler.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/__tests__/field-index-view-field-on-create-side-effect-handler.service.spec.ts @@ -312,22 +312,54 @@ describe('FieldIndexViewFieldOnCreateSideEffectHandlerService', () => { expect(result.status).toBe('success'); }); - it('should noop for a non-displayable field (relation) at object creation', () => { - const relationField = buildPendingFieldMetadata( - 'assignee', - FieldMetadataType.RELATION, - ); + // Parity with the existing-object path: a relation created in the same + // batch as its object gets a visible INDEX view field too, otherwise the + // same manifest yields different views depending on whether the object + // pre-existed (twentyhq/core-team-issues#2749). + it.each([ + ['relation', FieldMetadataType.RELATION], + ['morph relation', FieldMetadataType.MORPH_RELATION], + ])( + 'should emit a visible view field for a %s field created in the same batch as its object', + (_label, fieldMetadataType) => { + const relationField = buildPendingFieldMetadata( + 'assignee', + fieldMetadataType, + ); - const result = handler.buildSideEffects( - buildArgs({ - triggerFieldMetadata: relationField, - pendingFieldMetadatas: [NAME_FIELD, relationField], - objectMetadataCreatedInBatch: true, - }), - ); + const result = handler.buildSideEffects( + buildArgs({ + triggerFieldMetadata: relationField, + pendingFieldMetadatas: [NAME_FIELD, relationField], + objectMetadataCreatedInBatch: true, + }), + ); - expect(result.status).toBe('noop'); - }); + expect(result.status).toBe('success'); + + if (result.status !== 'success') { + throw new Error('expected success'); + } + + const viewFields = Object.values( + result.operations.viewField?.flatEntityToCreate ?? {}, + ); + + expect(viewFields).toHaveLength(1); + expect(viewFields[0].universalIdentifier).toBe( + computeViewFieldUniversalIdentifier({ + viewUniversalIdentifier: DERIVED_INDEX_VIEW_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: relationField.universalIdentifier, + }), + ); + expect(viewFields[0].viewUniversalIdentifier).toBe( + DERIVED_INDEX_VIEW_UNIVERSAL_IDENTIFIER, + ); + expect(viewFields[0].position).toBe(1); + expect(viewFields[0].isVisible).toBe(true); + expect(viewFields[0].isSystemSideEffect).toBe(true); + }, + ); }); describe('field created on an existing object (historical createOneField behavior)', () => { diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-index-view-field-on-create-side-effect-handler.service.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-index-view-field-on-create-side-effect-handler.service.ts index 99ab348062..68feee7661 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-index-view-field-on-create-side-effect-handler.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-index-view-field-on-create-side-effect-handler.service.ts @@ -17,8 +17,6 @@ import { MetadataSideEffectHandler, } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service'; import { type MetadataSideEffectResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type'; -import { computeFlatViewFieldsToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-view-fields-to-create.util'; -import { isFlatFieldMetadataDisplayableInDefaultView } from 'src/engine/metadata-modules/object-metadata/utils/is-flat-field-metadata-displayable-in-default-view.util'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type'; @@ -29,7 +27,7 @@ export class FieldIndexViewFieldOnCreateSideEffectHandlerService extends Metadat metadataName: 'fieldMetadata', name: 'fieldIndexViewFieldOnCreate', description: - 'When a field is created, provision its visible view field on the parent object INDEX view. Owns the view fields of every caller-provided field; engine-emitted fields get theirs from the handler that emits them.', + 'When a field is created, provision its visible view field on the parent object INDEX view. Owns the view fields of every caller-provided field, relations included; engine-emitted fields get theirs from the handler that emits them.', }, ) { buildSideEffects({ @@ -111,27 +109,16 @@ export class FieldIndexViewFieldOnCreateSideEffectHandlerService extends Metadat }; indexViewUniversalIdentifier: string; allFlatEntityOperationRecordByMetadataName: BuildSideEffectsArgs<'fieldMetadata'>['allFlatEntityOperationRecordByMetadataName']; - }): UniversalFlatViewField | undefined { + }): UniversalFlatViewField { const { labelIdentifierFieldMetadataUniversalIdentifier } = parentFlatObjectMetadata; - if ( - !isFlatFieldMetadataDisplayableInDefaultView({ - flatFieldMetadata: sourceFlatFieldMetadata, - labelIdentifierFieldMetadataUniversalIdentifier, - }) - ) { - return undefined; - } - - const displayableCallerFlatFieldMetadatas = - computeCallerFlatFieldMetadatasForObject({ - objectMetadataUniversalIdentifier: - sourceFlatFieldMetadata.objectMetadataUniversalIdentifier, - labelIdentifierFieldMetadataUniversalIdentifier, - allFlatEntityOperationRecordByMetadataName, - displayableOnly: true, - }); + const callerFlatFieldMetadatas = computeCallerFlatFieldMetadatasForObject({ + objectMetadataUniversalIdentifier: + sourceFlatFieldMetadata.objectMetadataUniversalIdentifier, + labelIdentifierFieldMetadataUniversalIdentifier, + allFlatEntityOperationRecordByMetadataName, + }); const positionByFieldUniversalIdentifier = computeDefaultIndexViewFieldPositionByFieldUniversalIdentifier({ @@ -140,7 +127,7 @@ export class FieldIndexViewFieldOnCreateSideEffectHandlerService extends Metadat objectMetadataUniversalIdentifier: sourceFlatFieldMetadata.objectMetadataUniversalIdentifier, labelIdentifierFieldMetadataUniversalIdentifier, - displayableCallerFlatFieldMetadatas, + callerFlatFieldMetadatas, }); const position = @@ -148,16 +135,11 @@ export class FieldIndexViewFieldOnCreateSideEffectHandlerService extends Metadat sourceFlatFieldMetadata.universalIdentifier, ) ?? 0; - const [flatViewFieldToCreate] = computeFlatViewFieldsToCreate({ - objectFlatFieldMetadatas: [sourceFlatFieldMetadata], - viewUniversalIdentifier: indexViewUniversalIdentifier, - applicationUniversalIdentifier: - sourceFlatFieldMetadata.applicationUniversalIdentifier, - labelIdentifierFieldMetadataUniversalIdentifier, - startPosition: position, + return this.buildIndexFlatViewFieldToCreate({ + sourceFlatFieldMetadata, + indexViewUniversalIdentifier, + position, }); - - return flatViewFieldToCreate; } private buildViewFieldForExistingObject({ @@ -213,17 +195,12 @@ export class FieldIndexViewFieldOnCreateSideEffectHandlerService extends Metadat existingActivePositions.length > 0 ? Math.min(...existingActivePositions) : 1; - const [flatLabelViewFieldToCreate] = computeFlatViewFieldsToCreate({ - objectFlatFieldMetadatas: [sourceFlatFieldMetadata], - viewUniversalIdentifier: indexViewUniversalIdentifier, - applicationUniversalIdentifier: - sourceFlatFieldMetadata.applicationUniversalIdentifier, - labelIdentifierFieldMetadataUniversalIdentifier: - parentFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier, - startPosition: lowestExistingPosition - 1, - }); - return flatLabelViewFieldToCreate; + return this.buildIndexFlatViewFieldToCreate({ + sourceFlatFieldMetadata, + indexViewUniversalIdentifier, + position: lowestExistingPosition - 1, + }); } const appendBasePosition = @@ -238,7 +215,6 @@ export class FieldIndexViewFieldOnCreateSideEffectHandlerService extends Metadat labelIdentifierFieldMetadataUniversalIdentifier: parentFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier, allFlatEntityOperationRecordByMetadataName, - displayableOnly: false, }); const indexAmongCallerFlatFieldMetadatas = Math.max( @@ -250,6 +226,22 @@ export class FieldIndexViewFieldOnCreateSideEffectHandlerService extends Metadat 0, ); + return this.buildIndexFlatViewFieldToCreate({ + sourceFlatFieldMetadata, + indexViewUniversalIdentifier, + position: appendBasePosition + indexAmongCallerFlatFieldMetadatas, + }); + } + + private buildIndexFlatViewFieldToCreate({ + sourceFlatFieldMetadata, + indexViewUniversalIdentifier, + position, + }: { + sourceFlatFieldMetadata: UniversalFlatFieldMetadata; + indexViewUniversalIdentifier: string; + position: number; + }): UniversalFlatViewField { const createdAt = new Date().toISOString(); const { applicationUniversalIdentifier } = sourceFlatFieldMetadata; @@ -268,7 +260,7 @@ export class FieldIndexViewFieldOnCreateSideEffectHandlerService extends Metadat viewFieldGroupUniversalIdentifier: null, isVisible: true, size: DEFAULT_VIEW_FIELD_SIZE, - position: appendBasePosition + indexAmongCallerFlatFieldMetadatas, + position, aggregateOperation: null, isActive: true, isSystemSideEffect: true, diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-fields-and-index-view-on-create-side-effect-handler.service.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-fields-and-index-view-on-create-side-effect-handler.service.ts index e73be3d0b3..89afa6873a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-fields-and-index-view-on-create-side-effect-handler.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-fields-and-index-view-on-create-side-effect-handler.service.ts @@ -58,14 +58,12 @@ export class ObjectSystemFieldsAndIndexViewOnCreateSideEffectHandlerService exte applicationUniversalIdentifier, }); - const displayableCallerFlatFieldMetadatas = - computeCallerFlatFieldMetadatasForObject({ - objectMetadataUniversalIdentifier: universalIdentifier, - labelIdentifierFieldMetadataUniversalIdentifier: - sourceFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier, - allFlatEntityOperationRecordByMetadataName, - displayableOnly: true, - }); + const callerFlatFieldMetadatas = computeCallerFlatFieldMetadatasForObject({ + objectMetadataUniversalIdentifier: universalIdentifier, + labelIdentifierFieldMetadataUniversalIdentifier: + sourceFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier, + allFlatEntityOperationRecordByMetadataName, + }); const positionByFieldUniversalIdentifier = computeDefaultIndexViewFieldPositionByFieldUniversalIdentifier({ @@ -73,7 +71,7 @@ export class ObjectSystemFieldsAndIndexViewOnCreateSideEffectHandlerService exte objectMetadataUniversalIdentifier: universalIdentifier, labelIdentifierFieldMetadataUniversalIdentifier: sourceFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier, - displayableCallerFlatFieldMetadatas, + callerFlatFieldMetadatas, }); const flatViewFieldsToCreate = computeFlatViewFieldsToCreate({ diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/utils/compute-caller-flat-field-metadatas-for-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/utils/compute-caller-flat-field-metadatas-for-object.util.ts index 69f5e1a856..f1be6f0a79 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/utils/compute-caller-flat-field-metadatas-for-object.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/utils/compute-caller-flat-field-metadatas-for-object.util.ts @@ -1,5 +1,4 @@ import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type'; -import { isFlatFieldMetadataDisplayableInDefaultView } from 'src/engine/metadata-modules/object-metadata/utils/is-flat-field-metadata-displayable-in-default-view.util'; import { orderFlatFieldMetadatasForSystemIndexView } from 'src/engine/metadata-modules/object-metadata/utils/order-flat-field-metadatas-for-system-index-view.util'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; @@ -7,12 +6,10 @@ export const computeCallerFlatFieldMetadatasForObject = ({ objectMetadataUniversalIdentifier, labelIdentifierFieldMetadataUniversalIdentifier, allFlatEntityOperationRecordByMetadataName, - displayableOnly, }: { objectMetadataUniversalIdentifier: string; labelIdentifierFieldMetadataUniversalIdentifier: string | null; allFlatEntityOperationRecordByMetadataName: AllFlatEntityOperationRecordByMetadataName; - displayableOnly: boolean; }): UniversalFlatFieldMetadata[] => orderFlatFieldMetadatasForSystemIndexView({ labelIdentifierFieldMetadataUniversalIdentifier, @@ -25,11 +22,6 @@ export const computeCallerFlatFieldMetadatasForObject = ({ (flatFieldMetadata) => flatFieldMetadata.objectMetadataUniversalIdentifier === objectMetadataUniversalIdentifier && - !flatFieldMetadata.isSystemSideEffect && - (!displayableOnly || - isFlatFieldMetadataDisplayableInDefaultView({ - flatFieldMetadata, - labelIdentifierFieldMetadataUniversalIdentifier, - })), + !flatFieldMetadata.isSystemSideEffect, ), }); diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/utils/compute-default-index-view-field-position-by-field-universal-identifier.util.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/utils/compute-default-index-view-field-position-by-field-universal-identifier.util.ts index f0fa9ccfbd..65619c4c7c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/utils/compute-default-index-view-field-position-by-field-universal-identifier.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-side-effect/handlers/utils/compute-default-index-view-field-position-by-field-universal-identifier.util.ts @@ -7,12 +7,12 @@ export const computeDefaultIndexViewFieldPositionByFieldUniversalIdentifier = ({ applicationUniversalIdentifier, objectMetadataUniversalIdentifier, labelIdentifierFieldMetadataUniversalIdentifier, - displayableCallerFlatFieldMetadatas, + callerFlatFieldMetadatas, }: { applicationUniversalIdentifier: string; objectMetadataUniversalIdentifier: string; labelIdentifierFieldMetadataUniversalIdentifier: string | null; - displayableCallerFlatFieldMetadatas: UniversalFlatFieldMetadata[]; + callerFlatFieldMetadatas: UniversalFlatFieldMetadata[]; }): Map => { const displayableSystemFlatFieldMetadatas = Object.values( buildReservedSystemFlatFieldMetadatasForCustomObject({ @@ -28,17 +28,16 @@ export const computeDefaultIndexViewFieldPositionByFieldUniversalIdentifier = ({ }), ); - const orderedDisplayableFlatFieldMetadatas = - orderFlatFieldMetadatasForSystemIndexView({ - labelIdentifierFieldMetadataUniversalIdentifier, - flatFieldMetadatas: [ - ...displayableCallerFlatFieldMetadatas, - ...displayableSystemFlatFieldMetadatas, - ], - }); + const orderedFlatFieldMetadatas = orderFlatFieldMetadatasForSystemIndexView({ + labelIdentifierFieldMetadataUniversalIdentifier, + flatFieldMetadatas: [ + ...callerFlatFieldMetadatas, + ...displayableSystemFlatFieldMetadatas, + ], + }); return new Map( - orderedDisplayableFlatFieldMetadatas.map((flatFieldMetadata, position) => [ + orderedFlatFieldMetadatas.map((flatFieldMetadata, position) => [ flatFieldMetadata.universalIdentifier, position, ]), diff --git a/packages/twenty-server/test/integration/metadata/suites/application/same-batch-relation-index-view-field-manifest-sync.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/same-batch-relation-index-view-field-manifest-sync.integration-spec.ts new file mode 100644 index 0000000000..dd59ae630f --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/same-batch-relation-index-view-field-manifest-sync.integration-spec.ts @@ -0,0 +1,249 @@ +import { VIEW_FIELD_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { findViewFields } from 'test/integration/metadata/suites/view-field/utils/find-view-fields.util'; +import { findViews } from 'test/integration/metadata/suites/view/utils/find-views.util'; +import { type ObjectManifest } from 'twenty-shared/application'; +import { + FieldMetadataType, + RelationOnDeleteAction, + RelationType, + ViewKey, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const TICKET_OBJECT_ID = uuidv4(); +const PROJECT_OBJECT_ID = uuidv4(); +const TICKET_NAME_FIELD_ID = uuidv4(); +const PROJECT_NAME_FIELD_ID = uuidv4(); +const TICKET_PROJECT_RELATION_FIELD_ID = uuidv4(); +const PROJECT_TICKETS_RELATION_FIELD_ID = uuidv4(); + +const TICKET_NAME_FIELD: ObjectManifest['fields'][number] = { + universalIdentifier: TICKET_NAME_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', +}; + +const PROJECT_NAME_FIELD: ObjectManifest['fields'][number] = { + universalIdentifier: PROJECT_NAME_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', +}; + +const TICKET_PROJECT_RELATION_FIELD: ObjectManifest['fields'][number] = { + universalIdentifier: TICKET_PROJECT_RELATION_FIELD_ID, + type: FieldMetadataType.RELATION, + name: 'project', + label: 'Project', + relationTargetFieldMetadataUniversalIdentifier: + PROJECT_TICKETS_RELATION_FIELD_ID, + relationTargetObjectMetadataUniversalIdentifier: PROJECT_OBJECT_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'projectId', + onDelete: RelationOnDeleteAction.SET_NULL, + }, +}; + +const PROJECT_TICKETS_RELATION_FIELD: ObjectManifest['fields'][number] = { + universalIdentifier: PROJECT_TICKETS_RELATION_FIELD_ID, + type: FieldMetadataType.RELATION, + name: 'tickets', + label: 'Tickets', + relationTargetFieldMetadataUniversalIdentifier: + TICKET_PROJECT_RELATION_FIELD_ID, + relationTargetObjectMetadataUniversalIdentifier: TICKET_OBJECT_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}; + +const buildManifest = ({ + includeRelationFields, +}: { + includeRelationFields: boolean; +}) => + buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + objects: [ + buildDefaultObjectManifest({ + applicationUniversalIdentifier: TEST_APP_ID, + universalIdentifier: TICKET_OBJECT_ID, + nameSingular: 'ticket', + namePlural: 'tickets', + labelSingular: 'Ticket', + labelPlural: 'Tickets', + description: 'A support ticket', + icon: 'IconTicket', + labelIdentifierFieldMetadataUniversalIdentifier: + TICKET_NAME_FIELD_ID, + additionalFields: [ + TICKET_NAME_FIELD, + ...(includeRelationFields ? [TICKET_PROJECT_RELATION_FIELD] : []), + ], + }), + buildDefaultObjectManifest({ + applicationUniversalIdentifier: TEST_APP_ID, + universalIdentifier: PROJECT_OBJECT_ID, + nameSingular: 'project', + namePlural: 'projects', + labelSingular: 'Project', + labelPlural: 'Projects', + description: 'A project', + icon: 'IconBriefcase', + labelIdentifierFieldMetadataUniversalIdentifier: + PROJECT_NAME_FIELD_ID, + additionalFields: [ + PROJECT_NAME_FIELD, + ...(includeRelationFields ? [PROJECT_TICKETS_RELATION_FIELD] : []), + ], + }), + ], + }, + }); + +const findObjectWithFields = async (objectUniversalIdentifier: string) => { + const { objects } = await findManyObjectMetadata({ + input: { + filter: {}, + paging: { first: 100 }, + }, + gqlFields: ` + id + universalIdentifier + fieldsList { + id + name + universalIdentifier + } + `, + expectToFail: false, + }); + + const object = objects.find( + (objectMetadata) => + objectMetadata.universalIdentifier === objectUniversalIdentifier, + ); + + if (!isDefined(object)) { + throw new Error( + `expected object ${objectUniversalIdentifier} to exist after sync`, + ); + } + + return object; +}; + +const findIndexViewFields = async (objectMetadataId: string) => { + const { data: viewsData } = await findViews({ + objectMetadataId, + gqlFields: 'id key', + expectToFail: false, + }); + + const indexView = viewsData?.getViews.find( + (view) => view.key === ViewKey.INDEX, + ); + + if (!isDefined(indexView)) { + throw new Error('expected an INDEX view for the object'); + } + + const { data: viewFieldsData } = await findViewFields({ + viewId: indexView.id, + gqlFields: VIEW_FIELD_GQL_FIELDS, + expectToFail: false, + }); + + return viewFieldsData?.getViewFields ?? []; +}; + +const expectRelationIndexViewFields = async () => { + const ticketObject = await findObjectWithFields(TICKET_OBJECT_ID); + const projectObject = await findObjectWithFields(PROJECT_OBJECT_ID); + + const ticketProjectField = ticketObject.fieldsList?.find( + (field) => + field.universalIdentifier === TICKET_PROJECT_RELATION_FIELD_ID, + ); + const projectTicketsField = projectObject.fieldsList?.find( + (field) => + field.universalIdentifier === PROJECT_TICKETS_RELATION_FIELD_ID, + ); + + expect(ticketProjectField).toBeDefined(); + expect(projectTicketsField).toBeDefined(); + + const ticketIndexViewFields = await findIndexViewFields(ticketObject.id); + const projectIndexViewFields = await findIndexViewFields(projectObject.id); + + const ticketProjectViewField = ticketIndexViewFields.find( + (viewField) => viewField.fieldMetadataId === ticketProjectField?.id, + ); + const projectTicketsViewField = projectIndexViewFields.find( + (viewField) => viewField.fieldMetadataId === projectTicketsField?.id, + ); + + expect(ticketProjectViewField).toBeDefined(); + expect(projectTicketsViewField).toBeDefined(); + + // Same visibility as a relation added to a pre-existing object. + expect(ticketProjectViewField?.isVisible).toBe(true); + expect(projectTicketsViewField?.isVisible).toBe(true); +}; + +describe('Manifest sync - INDEX view fields for relation fields created in the same batch as their objects', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Application', + description: + 'App for testing INDEX view fields of same-batch relation fields', + sourcePath: 'test-same-batch-relation-index-view-field', + }); + }, 60000); + + afterEach(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + // Reproduces twentyhq/core-team-issues#2749: relations created in the same + // sync as their objects get no INDEX view field at all, not even a hidden + // one, while the same relations added in a later sync do (next test). + it('should provision INDEX view fields for relation fields created in the same sync as their objects', async () => { + await syncApplication({ + manifest: buildManifest({ includeRelationFields: true }), + expectToFail: false, + }); + + await expectRelationIndexViewFields(); + }, 60000); + + it('should provision INDEX view fields for relation fields added to pre-existing objects in a second sync', async () => { + await syncApplication({ + manifest: buildManifest({ includeRelationFields: false }), + expectToFail: false, + }); + + await syncApplication({ + manifest: buildManifest({ includeRelationFields: true }), + expectToFail: false, + }); + + await expectRelationIndexViewFields(); + }, 60000); +});