diff --git a/.github/workflows/ci-example-app-postcard.yaml b/.github/workflows/ci-example-app-postcard.yaml index 28aaeac914..ab4ac47dc5 100644 --- a/.github/workflows/ci-example-app-postcard.yaml +++ b/.github/workflows/ci-example-app-postcard.yaml @@ -23,9 +23,11 @@ jobs: packages/twenty-sdk/** packages/twenty-client-sdk/** packages/twenty-shared/** + packages/twenty-server/** !packages/twenty-sdk/package.json !packages/twenty-client-sdk/package.json !packages/twenty-shared/package.json + !packages/twenty-server/package.json example-app-postcard: needs: changed-files-check @@ -83,6 +85,31 @@ jobs: working-directory: packages/twenty-apps/examples/postcard run: npx vitest run + - name: Configure remote for SDK CLI + run: | + mkdir -p ~/.twenty + cat > ~/.twenty/config.json <, +): FieldManifest & { objectUniversalIdentifier: string } => + ({ + universalIdentifier: FIELD_UID, + type: FieldMetadataType.TEXT, + name: 'demo', + label: 'Demo', + objectUniversalIdentifier: OBJECT_UID, + ...overrides, + }) as FieldManifest & { objectUniversalIdentifier: string }; + +describe('fromFieldManifestToUniversalFlatFieldMetadata', () => { + describe('composite defaultValue normalization', () => { + it('normalizes empty-name actor defaults to the canonical four-key shape', () => { + const result = fromFieldManifestToUniversalFlatFieldMetadata({ + fieldManifest: buildFieldManifest({ + type: FieldMetadataType.ACTOR, + name: 'createdBy', + label: 'Created by', + defaultValue: { name: "''", source: "'MANUAL'" }, + }), + applicationUniversalIdentifier: APP_UID, + now: NOW, + }); + + expect(result.defaultValue).toEqual({ + context: null, + name: null, + source: "'MANUAL'", + workspaceMemberId: null, + }); + }); + + it('is idempotent: re-running the converter on its own output yields the same defaultValue', () => { + const first = fromFieldManifestToUniversalFlatFieldMetadata({ + fieldManifest: buildFieldManifest({ + type: FieldMetadataType.ACTOR, + name: 'createdBy', + label: 'Created by', + defaultValue: { name: "''", source: "'MANUAL'" }, + }), + applicationUniversalIdentifier: APP_UID, + now: NOW, + }); + + const second = fromFieldManifestToUniversalFlatFieldMetadata({ + fieldManifest: buildFieldManifest({ + type: FieldMetadataType.ACTOR, + name: 'createdBy', + label: 'Created by', + defaultValue: first.defaultValue as FieldMetadataDefaultActor, + }), + applicationUniversalIdentifier: APP_UID, + now: NOW, + }); + + expect(second.defaultValue).toEqual(first.defaultValue); + }); + + it('falls back to the generated default and normalizes it when defaultValue is omitted', () => { + const result = fromFieldManifestToUniversalFlatFieldMetadata({ + fieldManifest: buildFieldManifest({ + type: FieldMetadataType.ACTOR, + name: 'updatedBy', + label: 'Updated by', + }), + applicationUniversalIdentifier: APP_UID, + now: NOW, + }); + + expect(result.defaultValue).toEqual({ + context: null, + name: "'System'", + source: "'MANUAL'", + workspaceMemberId: null, + }); + }); + + it('leaves non-composite defaults untouched', () => { + const result = fromFieldManifestToUniversalFlatFieldMetadata({ + fieldManifest: buildFieldManifest({ + type: FieldMetadataType.TEXT, + name: 'title', + label: 'Title', + defaultValue: "'todo'", + }), + applicationUniversalIdentifier: APP_UID, + now: NOW, + }); + + expect(result.defaultValue).toBe("'todo'"); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts index 8242906457..c957d94a5e 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts @@ -8,7 +8,10 @@ import { ApplicationException, ApplicationExceptionCode, } from 'src/engine/core-modules/application/application.exception'; +import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value'; +import { nullifyEmptyCompositeDefaultValue } from 'src/engine/metadata-modules/flat-field-metadata/utils/nullify-empty-composite-default-value.util'; import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant'; import { isMorphOrRelationFieldMetadataType } from 'src/engine/utils/is-morph-or-relation-field-metadata-type.util'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; @@ -65,6 +68,18 @@ export const fromFieldManifestToUniversalFlatFieldMetadata = ({ relationTargetObjectMetadataUniversalIdentifier, } = getRelationTargetUniversalIdentifiers(fieldManifest); + // TODO: generate system fields server-side from the object manifest + // so the converter doesn't need to re-normalize composite defaults + // that the SDK couldn't have known the canonical shape of. + const rawDefaultValue = + fieldManifest.defaultValue ?? generateDefaultValue(fieldManifest.type); + const defaultValue = isCompositeFieldMetadataType(fieldManifest.type) + ? nullifyEmptyCompositeDefaultValue({ + defaultValue: rawDefaultValue, + fieldType: fieldManifest.type as CompositeFieldMetadataType, + }) + : rawDefaultValue; + return { universalIdentifier: fieldManifest.universalIdentifier, applicationUniversalIdentifier, @@ -75,8 +90,7 @@ export const fromFieldManifestToUniversalFlatFieldMetadata = ({ icon: fieldManifest.icon ?? null, standardOverrides: null, options: fieldManifest.options ?? null, - defaultValue: - fieldManifest.defaultValue ?? generateDefaultValue(fieldManifest.type), + defaultValue, universalSettings: fieldManifest.universalSettings ?? null, isCustom: true, isActive: true, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap index c0a2c22fa6..0b0f2c825e 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/successful-sync-application-workspace-migration.integration-spec.ts.snap @@ -206,6 +206,7 @@ exports[`syncApplication should delete old field and create equivalent one when "applicationUniversalIdentifier": Any, "createdAt": Any, "defaultValue": { + "context": null, "name": "'System'", "source": "'MANUAL'", "workspaceMemberId": null, @@ -236,6 +237,7 @@ exports[`syncApplication should delete old field and create equivalent one when "applicationUniversalIdentifier": Any, "createdAt": Any, "defaultValue": { + "context": null, "name": "'System'", "source": "'MANUAL'", "workspaceMemberId": null, @@ -562,6 +564,7 @@ exports[`syncApplication should return workspace migration actions on initial sy "applicationUniversalIdentifier": Any, "createdAt": Any, "defaultValue": { + "context": null, "name": "'System'", "source": "'MANUAL'", "workspaceMemberId": null, @@ -592,6 +595,7 @@ exports[`syncApplication should return workspace migration actions on initial sy "applicationUniversalIdentifier": Any, "createdAt": Any, "defaultValue": { + "context": null, "name": "'System'", "source": "'MANUAL'", "workspaceMemberId": null,